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 21587a25 feat(java): describe physical batches and writes without a
format (#961)
21587a25 is described below
commit 21587a25e1849687ea607f33c97fc3f9d44c40d2
Author: alex <[email protected]>
AuthorDate: Wed Sep 2 00:56:03 2026 +0300
feat(java): describe physical batches and writes without a format (#961)
* feat(java): describe physical batches and writes without a format
The reader and writer verticals both need one neutral description of a
row group before either can name a file format. This adds that
description: a recursive column type, a named field, an ordered schema,
a row whose values follow one documented Java mapping, a finite record
batch, and a closeable cursor over batches.
The write half of the physical IO boundary rides along, because it is
the smaller consumer of those types and shares every one of them: a
write request binds a URI, a schema, and a target disposition, and a
physical writer drains a cursor into that target.
The module depends on nothing else in the project, so a format backend
can implement it without inheriting the metadata or storage layers.
Part of #947.
Rejected: exposing Arrow as the public batch representation, which
would make every consumer inherit an Arrow runtime for a contract that
only needs row access.
Not-tested: no format backend implements these interfaces yet; the
contract is pinned against an in-memory reference implementation.
* feat(java): keep physical batches columnar
Replace the provisional row access shape with a validated vector batch so
physical backends can align with Arrow without imposing its runtime on every
Java consumer. Preserve indexed duplicate schema fields, model recursive nested
shapes, and make append an explicit write disposition.\n\nPart of
#959.\n\nRejected: making Apache Arrow a mandatory dependency of
graphar-io-api.\n\nDirective: format adapters own vector lifetime and Java
value representation.\n\nNot-tested: no physical for [...]
* fix(java): complete IO API review contracts
Make column-kind classification exhaustive and give immutable schema and
write requests value semantics for deduplication and queued write planning.
Relates to #961.
---
maven-projects/io-api/pom.xml | 85 ++++++++
.../java/org/apache/graphar/io/BatchCursor.java | 35 ++++
.../java/org/apache/graphar/io/ColumnType.java | 229 +++++++++++++++++++++
.../src/main/java/org/apache/graphar/io/Field.java | 67 ++++++
.../java/org/apache/graphar/io/PhysicalWriter.java | 28 +++
.../java/org/apache/graphar/io/RecordBatch.java | 35 ++++
.../main/java/org/apache/graphar/io/Schema.java | 53 +++++
.../java/org/apache/graphar/io/ValueVector.java | 44 ++++
.../org/apache/graphar/io/VectorRecordBatch.java | 83 ++++++++
.../main/java/org/apache/graphar/io/WriteMode.java | 30 +++
.../java/org/apache/graphar/io/WriteRequest.java | 65 ++++++
.../java/org/apache/graphar/io/ColumnTypeTest.java | 111 ++++++++++
.../test/java/org/apache/graphar/io/FieldTest.java | 68 ++++++
.../org/apache/graphar/io/ListBatchCursor.java | 145 +++++++++++++
.../graphar/io/PhysicalWriterContractTest.java | 187 +++++++++++++++++
.../org/apache/graphar/io/RecordBatchTest.java | 146 +++++++++++++
.../java/org/apache/graphar/io/SchemaTest.java | 98 +++++++++
.../apache/graphar/io/VectorRecordBatchTest.java | 98 +++++++++
.../org/apache/graphar/io/WriteRequestTest.java | 88 ++++++++
maven-projects/pom.xml | 1 +
20 files changed, 1696 insertions(+)
diff --git a/maven-projects/io-api/pom.xml b/maven-projects/io-api/pom.xml
new file mode 100644
index 00000000..971f3090
--- /dev/null
+++ b/maven-projects/io-api/pom.xml
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.graphar</groupId>
+ <artifactId>graphar-root</artifactId>
+ <version>${graphar.version}</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>graphar-io-api</artifactId>
+ <packaging>jar</packaging>
+ <version>${graphar.version}</version>
+
+ <name>graphar-io-api</name>
+
+ <properties>
+ <maven.compiler.source>11</maven.compiler.source>
+ <maven.compiler.target>11</maven.compiler.target>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.13.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.diffplug.spotless</groupId>
+ <artifactId>spotless-maven-plugin</artifactId>
+ <version>${spotless-maven-plugin.version}</version>
+ <configuration>
+ <java>
+ <googleJavaFormat>
+ <version>1.7</version>
+ <style>AOSP</style>
+ </googleJavaFormat>
+ </java>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-javadoc-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-javadocs</id>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/BatchCursor.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/BatchCursor.java
new file mode 100644
index 00000000..222fe9c9
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/BatchCursor.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.Closeable;
+import java.io.IOException;
+
+/** A closeable cursor over neutral, format-independent record batches. */
+public interface BatchCursor extends Closeable {
+ /** Advances to the next batch, returning {@code false} after the final
batch. */
+ boolean next() throws IOException;
+
+ /** Returns the current batch after {@link #next()} returns {@code true}.
*/
+ RecordBatch batch();
+
+ @Override
+ void close() throws IOException;
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnType.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnType.java
new file mode 100644
index 00000000..6ed8d6bc
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ColumnType.java
@@ -0,0 +1,229 @@
+/*
+ * 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.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.OptionalInt;
+
+/** A recursive, Arrow-shaped logical type used in a physical {@link Schema}.
*/
+public final class ColumnType {
+ /** Scalar, fixed-width, and nested shapes that a physical format may
describe. */
+ public enum Kind {
+ BOOLEAN,
+ INT8,
+ INT16,
+ INT32,
+ INT64,
+ UINT8,
+ UINT16,
+ UINT32,
+ UINT64,
+ FLOAT32,
+ FLOAT64,
+ STRING,
+ BINARY,
+ DATE,
+ TIMESTAMP_MILLIS,
+ FIXED_SIZE_BINARY,
+ DECIMAL,
+ LIST,
+ FIXED_SIZE_LIST,
+ STRUCT,
+ MAP
+ }
+
+ private final Kind kind;
+ private final List<Field> children;
+ private final int fixedSize;
+ private final int precision;
+ private final int scale;
+
+ private ColumnType(Kind kind, List<Field> children, int fixedSize, int
precision, int scale) {
+ this.kind = kind;
+ this.children = children;
+ this.fixedSize = fixedSize;
+ this.precision = precision;
+ this.scale = scale;
+ }
+
+ /** Creates one of the scalar kinds. */
+ public static ColumnType of(Kind kind) {
+ Objects.requireNonNull(kind, "A column type kind cannot be null.");
+ if (!isScalar(kind)) {
+ throw new IllegalArgumentException("Use a dedicated factory for "
+ kind + ".");
+ }
+ return new ColumnType(kind, List.of(), 0, 0, 0);
+ }
+
+ /** Creates a list whose anonymous element has {@code elementType}. */
+ public static ColumnType listOf(ColumnType elementType) {
+ return listOfElement(new Field("element", elementType, true));
+ }
+
+ /** Creates a list whose element field retains its physical name and
nullability. */
+ public static ColumnType listOfElement(Field elementField) {
+ return collection(Kind.LIST, elementField, 0);
+ }
+
+ /** Creates a fixed-size list whose every value contains exactly {@code
listSize} elements. */
+ public static ColumnType fixedSizeListOf(Field elementField, int listSize)
{
+ if (listSize <= 0) {
+ throw new IllegalArgumentException("A fixed-size list must have a
positive size.");
+ }
+ return collection(Kind.FIXED_SIZE_LIST, elementField, listSize);
+ }
+
+ /** Creates a struct with the supplied named children in physical order. */
+ public static ColumnType structOf(List<Field> fields) {
+ return new ColumnType(Kind.STRUCT, copyFields(fields), 0, 0, 0);
+ }
+
+ /** Creates a map with the physical key and value fields in that order. */
+ public static ColumnType mapOf(Field key, Field value) {
+ Objects.requireNonNull(key, "A map key field cannot be null.");
+ if (key.nullable()) {
+ throw new IllegalArgumentException("A map key field cannot be
nullable.");
+ }
+ return new ColumnType(
+ Kind.MAP,
+ List.of(key, Objects.requireNonNull(value, "A map value field
cannot be null.")),
+ 0,
+ 0,
+ 0);
+ }
+
+ /** Creates a fixed-width binary value with exactly {@code byteWidth}
bytes. */
+ public static ColumnType fixedSizeBinary(int byteWidth) {
+ if (byteWidth <= 0) {
+ throw new IllegalArgumentException("Fixed-size binary width must
be positive.");
+ }
+ return new ColumnType(Kind.FIXED_SIZE_BINARY, List.of(), byteWidth, 0,
0);
+ }
+
+ /** Creates a decimal whose scale is between zero and its positive
precision. */
+ public static ColumnType decimal(int precision, int scale) {
+ if (precision <= 0 || scale < 0 || scale > precision) {
+ throw new IllegalArgumentException(
+ "Decimal requires 0 <= scale <= positive precision.");
+ }
+ return new ColumnType(Kind.DECIMAL, List.of(), 0, precision, scale);
+ }
+
+ /** Returns this type's shape. */
+ public Kind kind() {
+ return kind;
+ }
+
+ /** Returns child fields in physical order. Scalar types have no children.
*/
+ public List<Field> children() {
+ return children;
+ }
+
+ /** Returns the list element type for a list shape, or empty for all other
shapes. */
+ public Optional<ColumnType> elementType() {
+ return (kind == Kind.LIST || kind == Kind.FIXED_SIZE_LIST)
+ ? Optional.of(children.get(0).type())
+ : Optional.empty();
+ }
+
+ /** Returns a fixed binary width or fixed-list size when one applies. */
+ public OptionalInt fixedSize() {
+ return fixedSize == 0 ? OptionalInt.empty() :
OptionalInt.of(fixedSize);
+ }
+
+ /** Returns decimal precision for a decimal type. */
+ public OptionalInt precision() {
+ return precision == 0 ? OptionalInt.empty() :
OptionalInt.of(precision);
+ }
+
+ /** Returns decimal scale for a decimal type. */
+ public OptionalInt scale() {
+ return kind == Kind.DECIMAL ? OptionalInt.of(scale) :
OptionalInt.empty();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof ColumnType)) {
+ return false;
+ }
+ ColumnType that = (ColumnType) other;
+ return kind == that.kind
+ && fixedSize == that.fixedSize
+ && precision == that.precision
+ && scale == that.scale
+ && children.equals(that.children);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(kind, children, fixedSize, precision, scale);
+ }
+
+ private static ColumnType collection(Kind kind, Field elementField, int
fixedSize) {
+ return new ColumnType(
+ kind,
+ List.of(
+ Objects.requireNonNull(
+ elementField, "A list element field cannot be
null.")),
+ fixedSize,
+ 0,
+ 0);
+ }
+
+ private static List<Field> copyFields(List<Field> fields) {
+ Objects.requireNonNull(fields, "Child fields cannot be null.");
+ for (Field field : fields) {
+ Objects.requireNonNull(field, "A child field cannot be null.");
+ }
+ return List.copyOf(fields);
+ }
+
+ private static boolean isScalar(Kind kind) {
+ switch (kind) {
+ case BOOLEAN:
+ case INT8:
+ case INT16:
+ case INT32:
+ case INT64:
+ case UINT8:
+ case UINT16:
+ case UINT32:
+ case UINT64:
+ case FLOAT32:
+ case FLOAT64:
+ case STRING:
+ case BINARY:
+ case DATE:
+ case TIMESTAMP_MILLIS:
+ return true;
+ case FIXED_SIZE_BINARY:
+ case DECIMAL:
+ case LIST:
+ case FIXED_SIZE_LIST:
+ case STRUCT:
+ case MAP:
+ return false;
+ default:
+ throw new IllegalStateException("Unhandled column type kind: "
+ kind);
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/Field.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Field.java
new file mode 100644
index 00000000..e9437d0b
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Field.java
@@ -0,0 +1,67 @@
+/*
+ * 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 named, typed physical column in a {@link Schema}. */
+public final class Field {
+ private final String name;
+ private final ColumnType type;
+ private final boolean nullable;
+
+ public Field(String name, ColumnType type, boolean nullable) {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("A field name cannot be
blank.");
+ }
+ this.name = name;
+ this.type = Objects.requireNonNull(type, "A field type cannot be
null.");
+ this.nullable = nullable;
+ }
+
+ /** Returns this column name. */
+ public String name() {
+ return name;
+ }
+
+ /** Returns this column's neutral physical type. */
+ public ColumnType type() {
+ return type;
+ }
+
+ /** Returns whether this column may contain null values. */
+ public boolean nullable() {
+ return nullable;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof Field)) {
+ return false;
+ }
+ Field that = (Field) other;
+ return nullable == that.nullable && name.equals(that.name) &&
type.equals(that.type);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, type, nullable);
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalWriter.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalWriter.java
new file mode 100644
index 00000000..2365e001
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/PhysicalWriter.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;
+
+import java.io.IOException;
+
+/** A format-specific writer behind the GraphAr physical IO boundary. */
+public interface PhysicalWriter {
+ /** Writes every batch from {@code batches} according to the immutable
physical operation. */
+ void write(WriteRequest request, BatchCursor batches) throws IOException;
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/RecordBatch.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/RecordBatch.java
new file mode 100644
index 00000000..f3834240
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/RecordBatch.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;
+
+/** A format-neutral, finite set of same-length vectors sharing one schema. */
+public interface RecordBatch {
+ /** Returns the schema that defines the vectors in physical column order.
*/
+ Schema schema();
+
+ /** Returns the number of rows in this batch. */
+ int rowCount();
+
+ /** Returns the number of vectors in this batch. */
+ int columnCount();
+
+ /** Returns the vector at zero-based physical {@code index}. */
+ ValueVector column(int index);
+}
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
new file mode 100644
index 00000000..c7b347f1
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/Schema.java
@@ -0,0 +1,53 @@
+/*
+ * 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.List;
+import java.util.Objects;
+
+/** An ordered, immutable mapping of physical batch columns to neutral field
definitions. */
+public final class Schema {
+ private final List<Field> fields;
+
+ public Schema(List<Field> fields) {
+ Objects.requireNonNull(fields, "Schema fields cannot be null.");
+ List<Field> copy = new ArrayList<>(fields.size());
+ for (Field field : fields) {
+ copy.add(Objects.requireNonNull(field, "A schema field cannot be
null."));
+ }
+ this.fields = List.copyOf(copy);
+ }
+
+ /** Returns immutable fields in physical column order. */
+ public List<Field> fields() {
+ return fields;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return other instanceof Schema && fields.equals(((Schema)
other).fields);
+ }
+
+ @Override
+ public int hashCode() {
+ return fields.hashCode();
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/ValueVector.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ValueVector.java
new file mode 100644
index 00000000..df8ae70a
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/ValueVector.java
@@ -0,0 +1,44 @@
+/*
+ * 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;
+
+/**
+ * One typed, columnar sequence in a {@link RecordBatch}.
+ *
+ * <p>This deliberately follows the read shape of an Apache Arrow value vector
without making the
+ * dependency-light IO API own Arrow buffers or allocators. A format adapter
may expose its native
+ * vector directly when its lifetime permits, or adapt it through this
interface.
+ */
+public interface ValueVector {
+ /** Returns the field that describes values in this vector. */
+ Field field();
+
+ /** Returns the number of values in this vector. */
+ int valueCount();
+
+ /** Returns whether the value at zero-based {@code index} is null. */
+ boolean isNull(int index);
+
+ /**
+ * Returns the value at zero-based {@code index}, or {@code null} when
{@link #isNull(int)} is
+ * true. The Java representation is defined by the vector implementation
and its {@link Field}.
+ */
+ Object getObject(int index);
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/VectorRecordBatch.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/VectorRecordBatch.java
new file mode 100644
index 00000000..9c4847f7
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/VectorRecordBatch.java
@@ -0,0 +1,83 @@
+/*
+ * 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.List;
+import java.util.Objects;
+
+/** A validated {@link RecordBatch} backed by one vector for each schema
field. */
+public final class VectorRecordBatch implements RecordBatch {
+ private final Schema schema;
+ private final List<ValueVector> columns;
+ private final int rowCount;
+
+ /**
+ * Creates a batch whose vectors have exactly {@code rowCount} values and
match {@code schema}
+ * by physical position.
+ */
+ public VectorRecordBatch(Schema schema, List<ValueVector> columns, int
rowCount) {
+ this.schema = Objects.requireNonNull(schema, "A batch schema cannot be
null.");
+ if (rowCount < 0) {
+ throw new IllegalArgumentException("A batch row count cannot be
negative.");
+ }
+ Objects.requireNonNull(columns, "Batch vectors cannot be null.");
+ if (schema.fields().size() != columns.size()) {
+ throw new IllegalArgumentException(
+ "A batch must contain one vector for every schema field.");
+ }
+ List<ValueVector> copy = new ArrayList<>(columns.size());
+ for (int index = 0; index < columns.size(); index++) {
+ ValueVector vector =
+ Objects.requireNonNull(columns.get(index), "A batch vector
cannot be null.");
+ if (!schema.fields().get(index).equals(vector.field())) {
+ throw new IllegalArgumentException(
+ "A batch vector does not match its schema field.");
+ }
+ if (vector.valueCount() != rowCount) {
+ throw new IllegalArgumentException(
+ "Every batch vector must have the batch row count.");
+ }
+ copy.add(vector);
+ }
+ this.columns = List.copyOf(copy);
+ this.rowCount = rowCount;
+ }
+
+ @Override
+ public Schema schema() {
+ return schema;
+ }
+
+ @Override
+ public int rowCount() {
+ return rowCount;
+ }
+
+ @Override
+ public int columnCount() {
+ return columns.size();
+ }
+
+ @Override
+ public ValueVector column(int index) {
+ return columns.get(index);
+ }
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteMode.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteMode.java
new file mode 100644
index 00000000..5598bcfa
--- /dev/null
+++ b/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteMode.java
@@ -0,0 +1,30 @@
+/*
+ * 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 disposition required for one physical output location. */
+public enum WriteMode {
+ /** Fail when the target already exists. */
+ CREATE_NEW,
+ /** Replace any existing target. */
+ OVERWRITE,
+ /** Add supplied batches after the existing physical data at the target. */
+ APPEND
+}
diff --git
a/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteRequest.java
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteRequest.java
new file mode 100644
index 00000000..c4262c6b
--- /dev/null
+++
b/maven-projects/io-api/src/main/java/org/apache/graphar/io/WriteRequest.java
@@ -0,0 +1,65 @@
+/*
+ * 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.Objects;
+
+/** An immutable physical write operation for one schema and output location.
*/
+public final class WriteRequest {
+ private final URI uri;
+ private final Schema schema;
+ private final WriteMode mode;
+
+ public WriteRequest(URI uri, Schema schema, WriteMode mode) {
+ this.uri = Objects.requireNonNull(uri, "A write URI cannot be null.");
+ this.schema = Objects.requireNonNull(schema, "A write schema cannot be
null.");
+ this.mode = Objects.requireNonNull(mode, "A write mode cannot be
null.");
+ }
+
+ /** Returns the logical location of the physical output. */
+ public URI uri() {
+ return uri;
+ }
+
+ /** Returns the schema every supplied batch must use. */
+ public Schema schema() {
+ return schema;
+ }
+
+ /** Returns the target-existence disposition. */
+ public WriteMode mode() {
+ return mode;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof WriteRequest)) {
+ return false;
+ }
+ WriteRequest that = (WriteRequest) other;
+ return uri.equals(that.uri) && schema.equals(that.schema) && mode ==
that.mode;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uri, schema, mode);
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnTypeTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnTypeTest.java
new file mode 100644
index 00000000..95793e23
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ColumnTypeTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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 static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+import org.junit.Test;
+
+public class ColumnTypeTest {
+
+ @Test
+ public void describesAScalarKindWithoutAnElementType() {
+ ColumnType type = ColumnType.of(ColumnType.Kind.INT64);
+
+ assertEquals(ColumnType.Kind.INT64, type.kind());
+ assertEquals(Optional.empty(), type.elementType());
+ }
+
+ @Test
+ public void describesANestedListThroughItsElementType() {
+ ColumnType inner = ColumnType.of(ColumnType.Kind.STRING);
+ ColumnType nested = ColumnType.listOf(ColumnType.listOf(inner));
+
+ assertEquals(ColumnType.Kind.LIST, nested.kind());
+ assertEquals(ColumnType.Kind.LIST,
nested.elementType().orElseThrow().kind());
+ assertEquals(inner,
nested.elementType().orElseThrow().elementType().orElseThrow());
+ }
+
+ @Test
+ public void refusesToBuildAListThroughTheScalarFactory() {
+ IllegalArgumentException failure =
+ assertThrows(
+ IllegalArgumentException.class, () ->
ColumnType.of(ColumnType.Kind.LIST));
+
+ assertTrue(failure.getMessage().contains("dedicated factory"));
+ }
+
+ @Test
+ public void retainsNamesAndNullabilityInNestedShapes() {
+ Field element = new Field("item",
ColumnType.of(ColumnType.Kind.INT32), false);
+ Field attribute = new Field("weight",
ColumnType.of(ColumnType.Kind.FLOAT64), true);
+ ColumnType type =
+ ColumnType.structOf(
+ List.of(
+ new Field("ids",
ColumnType.fixedSizeListOf(element, 3), false),
+ attribute));
+
+ assertEquals(ColumnType.Kind.STRUCT, type.kind());
+ assertEquals("ids", type.children().get(0).name());
+ assertEquals(OptionalInt.of(3),
type.children().get(0).type().fixedSize());
+ assertEquals(element.type(),
type.children().get(0).type().elementType().orElseThrow());
+ assertTrue(attribute.nullable());
+ }
+
+ @Test
+ public void modelsMapDecimalAndFixedWidthBinaryWithoutAmbiguousFactories()
{
+ Field key = new Field("key", ColumnType.of(ColumnType.Kind.STRING),
false);
+ Field value = new Field("value", ColumnType.decimal(12, 2), true);
+ ColumnType map = ColumnType.mapOf(key, value);
+ ColumnType binary = ColumnType.fixedSizeBinary(16);
+
+ assertEquals(List.of(key, value), map.children());
+ assertEquals(OptionalInt.of(12), value.type().precision());
+ assertEquals(OptionalInt.of(2), value.type().scale());
+ assertEquals(OptionalInt.of(16), binary.fixedSize());
+ assertThrows(IllegalArgumentException.class, () ->
ColumnType.mapOf(value, key));
+ assertThrows(IllegalArgumentException.class, () ->
ColumnType.decimal(0, 0));
+ assertThrows(IllegalArgumentException.class, () ->
ColumnType.fixedSizeBinary(0));
+ }
+
+ @Test
+ public void refusesAMissingKindOrElementType() {
+ assertThrows(NullPointerException.class, () -> ColumnType.of(null));
+ assertThrows(NullPointerException.class, () ->
ColumnType.listOf((ColumnType) null));
+ }
+
+ @Test
+ public void comparesEqualOnlyWhenKindAndElementTypeMatch() {
+ ColumnType listOfInt =
ColumnType.listOf(ColumnType.of(ColumnType.Kind.INT32));
+ ColumnType sameListOfInt =
ColumnType.listOf(ColumnType.of(ColumnType.Kind.INT32));
+ ColumnType listOfLong =
ColumnType.listOf(ColumnType.of(ColumnType.Kind.INT64));
+
+ assertEquals(listOfInt, sameListOfInt);
+ assertEquals(listOfInt.hashCode(), sameListOfInt.hashCode());
+ assertNotEquals(listOfInt, listOfLong);
+ assertNotEquals(listOfInt, ColumnType.of(ColumnType.Kind.INT32));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/FieldTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/FieldTest.java
new file mode 100644
index 00000000..051a6fc7
--- /dev/null
+++ b/maven-projects/io-api/src/test/java/org/apache/graphar/io/FieldTest.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.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class FieldTest {
+
+ @Test
+ public void carriesItsNameTypeAndNullability() {
+ ColumnType type = ColumnType.of(ColumnType.Kind.STRING);
+ Field nullable = new Field("id", type, true);
+ Field required = new Field("id", type, false);
+
+ assertEquals("id", nullable.name());
+ assertEquals(type, nullable.type());
+ assertTrue(nullable.nullable());
+ assertFalse(required.nullable());
+ }
+
+ @Test
+ public void refusesABlankName() {
+ ColumnType type = ColumnType.of(ColumnType.Kind.STRING);
+
+ assertThrows(IllegalArgumentException.class, () -> new Field(null,
type, true));
+ assertThrows(IllegalArgumentException.class, () -> new Field("", type,
true));
+ assertThrows(IllegalArgumentException.class, () -> new Field(" ",
type, true));
+ }
+
+ @Test
+ public void refusesAMissingType() {
+ assertThrows(NullPointerException.class, () -> new Field("id", null,
true));
+ }
+
+ @Test
+ public void comparesAllSchemaRelevantValues() {
+ Field id = new Field("id", ColumnType.of(ColumnType.Kind.INT64),
false);
+ Field sameId = new Field("id", ColumnType.of(ColumnType.Kind.INT64),
false);
+
+ assertEquals(id, sameId);
+ assertEquals(id.hashCode(), sameId.hashCode());
+ assertNotEquals(id, new Field("id",
ColumnType.of(ColumnType.Kind.INT32), false));
+ assertNotEquals(id, new Field("id",
ColumnType.of(ColumnType.Kind.INT64), true));
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/ListBatchCursor.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ListBatchCursor.java
new file mode 100644
index 00000000..e80eb3d7
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/ListBatchCursor.java
@@ -0,0 +1,145 @@
+/*
+ * 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.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/** An in-memory reference {@link BatchCursor} used to exercise the neutral
batch contract. */
+final class ListBatchCursor implements BatchCursor {
+ private final Schema schema;
+ private final List<List<List<Object>>> batches;
+ private int index = -1;
+ private int closeCount;
+
+ ListBatchCursor(Schema schema, List<List<List<Object>>> batches) {
+ this.schema = Objects.requireNonNull(schema);
+ this.batches = batches;
+ }
+
+ @Override
+ public boolean next() {
+ if (index + 1 >= batches.size()) {
+ return false;
+ }
+ index++;
+ return true;
+ }
+
+ @Override
+ public RecordBatch batch() {
+ if (index < 0 || index >= batches.size()) {
+ throw new IllegalStateException("No batch is current.");
+ }
+ return new ListRecordBatch(schema, batches.get(index));
+ }
+
+ @Override
+ public void close() {
+ closeCount++;
+ }
+
+ int closeCount() {
+ return closeCount;
+ }
+
+ private static final class ListRecordBatch implements RecordBatch {
+ private final VectorRecordBatch delegate;
+
+ private ListRecordBatch(Schema schema, List<List<Object>> rows) {
+ List<List<Object>> valuesByColumn = new
ArrayList<>(schema.fields().size());
+ for (int column = 0; column < schema.fields().size(); column++) {
+ valuesByColumn.add(new ArrayList<>(rows.size()));
+ }
+ for (List<Object> row : rows) {
+ Objects.requireNonNull(row, "A batch row cannot be null.");
+ if (row.size() != schema.fields().size()) {
+ throw new IllegalArgumentException(
+ "A batch row does not match the schema width.");
+ }
+ for (int column = 0; column < row.size(); column++) {
+ valuesByColumn.get(column).add(row.get(column));
+ }
+ }
+ List<ValueVector> columns = new ArrayList<>(valuesByColumn.size());
+ for (int column = 0; column < valuesByColumn.size(); column++) {
+ columns.add(
+ new ListValueVector(
+ schema.fields().get(column),
valuesByColumn.get(column)));
+ }
+ this.delegate = new VectorRecordBatch(schema, columns,
rows.size());
+ }
+
+ @Override
+ public Schema schema() {
+ return delegate.schema();
+ }
+
+ @Override
+ public int rowCount() {
+ return delegate.rowCount();
+ }
+
+ @Override
+ public int columnCount() {
+ return delegate.columnCount();
+ }
+
+ @Override
+ public ValueVector column(int columnIndex) {
+ return delegate.column(columnIndex);
+ }
+ }
+
+ 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 = Collections.unmodifiableList(new
ArrayList<>(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) {
+ Object value = values.get(index);
+ return value instanceof List
+ ? Collections.unmodifiableList(new ArrayList<>((List<?>)
value))
+ : value;
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalWriterContractTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalWriterContractTest.java
new file mode 100644
index 00000000..0b275511
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/PhysicalWriterContractTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+/**
+ * Pins the obligations every {@link PhysicalWriter} owes its caller, against
an in-memory reference
+ * implementation that stands in for a real format backend.
+ */
+public class PhysicalWriterContractTest {
+
+ private static final URI TARGET =
URI.create("memory:/vertex/person/chunk0");
+
+ private static Schema schema() {
+ return new Schema(
+ Collections.singletonList(
+ new Field("id", ColumnType.of(ColumnType.Kind.INT64),
false)));
+ }
+
+ private static ListBatchCursor cursorOf(long... ids) {
+ List<List<Object>> rows = new ArrayList<>();
+ for (long id : ids) {
+ rows.add(Collections.singletonList(id));
+ }
+ return new ListBatchCursor(schema(), Collections.singletonList(rows));
+ }
+
+ private static final class MemoryWriter implements PhysicalWriter {
+ private final Map<URI, List<Object>> written = new HashMap<>();
+
+ @Override
+ public void write(WriteRequest request, BatchCursor batches) throws
IOException {
+ if (request.mode() == WriteMode.CREATE_NEW &&
written.containsKey(request.uri())) {
+ throw new IOException("Target already exists: " +
request.uri());
+ }
+ List<Object> values =
+ request.mode() == WriteMode.APPEND &&
written.containsKey(request.uri())
+ ? new ArrayList<>(written.get(request.uri()))
+ : new ArrayList<>();
+ while (batches.next()) {
+ RecordBatch batch = batches.batch();
+ if (!describes(batch.schema(), request.schema())) {
+ throw new IOException("A batch does not match the
requested schema.");
+ }
+ ValueVector valuesVector = batch.column(0);
+ if (valuesVector.valueCount() != batch.rowCount()) {
+ throw new IOException("A vector does not match the batch
row count.");
+ }
+ for (int row = 0; row < batch.rowCount(); row++) {
+ values.add(valuesVector.getObject(row));
+ }
+ }
+ written.put(request.uri(), values);
+ }
+
+ private static boolean describes(Schema batchSchema, Schema requested)
{
+ List<Field> left = batchSchema.fields();
+ List<Field> right = requested.fields();
+ if (left.size() != right.size()) {
+ return false;
+ }
+ for (int column = 0; column < left.size(); column++) {
+ if (!left.get(column).name().equals(right.get(column).name())
+ ||
!left.get(column).type().equals(right.get(column).type())) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ @Test
+ public void writesEveryRowOfEveryBatchToTheRequestedTarget() throws
IOException {
+ MemoryWriter writer = new MemoryWriter();
+
+ writer.write(
+ new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
cursorOf(1L, 2L, 3L));
+
+ assertEquals(Arrays.asList(1L, 2L, 3L), writer.written.get(TARGET));
+ }
+
+ @Test
+ public void refusesToReplaceAnExistingTargetUnderCreateNew() throws
IOException {
+ MemoryWriter writer = new MemoryWriter();
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
cursorOf(1L));
+
+ IOException failure =
+ assertThrows(
+ IOException.class,
+ () ->
+ writer.write(
+ new WriteRequest(TARGET, schema(),
WriteMode.CREATE_NEW),
+ cursorOf(2L)));
+
+ assertTrue(failure.getMessage().contains(TARGET.toString()));
+ assertEquals(Collections.singletonList(1L),
writer.written.get(TARGET));
+ }
+
+ @Test
+ public void replacesAnExistingTargetUnderOverwrite() throws IOException {
+ MemoryWriter writer = new MemoryWriter();
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
cursorOf(1L));
+
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.OVERWRITE),
cursorOf(2L, 3L));
+
+ assertEquals(Arrays.asList(2L, 3L), writer.written.get(TARGET));
+ }
+
+ @Test
+ public void extendsAnExistingTargetUnderAppend() throws IOException {
+ MemoryWriter writer = new MemoryWriter();
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
cursorOf(1L));
+
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.APPEND),
cursorOf(2L, 3L));
+
+ assertEquals(Arrays.asList(1L, 2L, 3L), writer.written.get(TARGET));
+ }
+
+ @Test
+ public void acceptsAnEmptyCursorAndStillCreatesTheTarget() throws
IOException {
+ MemoryWriter writer = new MemoryWriter();
+
+ writer.write(
+ new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
+ new ListBatchCursor(schema(), Collections.emptyList()));
+
+ assertEquals(Collections.emptyList(), writer.written.get(TARGET));
+ }
+
+ @Test
+ public void refusesABatchThatDoesNotMatchTheRequestedSchema() {
+ MemoryWriter writer = new MemoryWriter();
+ Schema other =
+ new Schema(
+ Collections.singletonList(
+ new Field("other",
ColumnType.of(ColumnType.Kind.INT64), false)));
+
+ assertThrows(
+ IOException.class,
+ () ->
+ writer.write(
+ new WriteRequest(TARGET, other,
WriteMode.CREATE_NEW),
+ cursorOf(1L)));
+ }
+
+ @Test
+ public void leavesTheSuppliedCursorForItsOwnerToClose() throws IOException
{
+ MemoryWriter writer = new MemoryWriter();
+ ListBatchCursor cursor = cursorOf(1L, 2L);
+
+ writer.write(new WriteRequest(TARGET, schema(), WriteMode.CREATE_NEW),
cursor);
+
+ assertEquals(0, cursor.closeCount());
+ cursor.close();
+ assertEquals(1, cursor.closeCount());
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/RecordBatchTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/RecordBatchTest.java
new file mode 100644
index 00000000..f9466963
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/RecordBatchTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+public class RecordBatchTest {
+
+ private static Schema schema() {
+ return new Schema(
+ Arrays.asList(
+ new Field("flag",
ColumnType.of(ColumnType.Kind.BOOLEAN), false),
+ new Field("count",
ColumnType.of(ColumnType.Kind.INT64), false),
+ new Field("label",
ColumnType.of(ColumnType.Kind.STRING), true),
+ new Field("blob",
ColumnType.of(ColumnType.Kind.BINARY), false),
+ new Field("day", ColumnType.of(ColumnType.Kind.DATE),
false),
+ new Field("at",
ColumnType.of(ColumnType.Kind.TIMESTAMP_MILLIS), false),
+ new Field(
+ "tags",
+
ColumnType.listOf(ColumnType.of(ColumnType.Kind.STRING)),
+ false)));
+ }
+
+ private static List<Object> row() {
+ return Arrays.asList(
+ Boolean.TRUE,
+ 7L,
+ null,
+
ByteBuffer.wrap("gar".getBytes(StandardCharsets.UTF_8)).asReadOnlyBuffer(),
+ LocalDate.of(2024, 5, 17),
+ Instant.ofEpochMilli(1_715_000_000_000L),
+ Arrays.asList("a", "b"));
+ }
+
+ @Test
+ public void mapsEveryDocumentedColumnKindToItsNeutralJavaValue() throws
IOException {
+ try (ListBatchCursor cursor =
+ new ListBatchCursor(
+ schema(),
Collections.singletonList(Collections.singletonList(row())))) {
+ assertTrue(cursor.next());
+ RecordBatch batch = cursor.batch();
+
+ assertEquals(7, batch.columnCount());
+ assertEquals(Boolean.TRUE, batch.column(0).getObject(0));
+ assertEquals(Long.valueOf(7L), batch.column(1).getObject(0));
+ assertTrue(batch.column(2).isNull(0));
+ assertNull(batch.column(2).getObject(0));
+ assertTrue(((ByteBuffer)
batch.column(3).getObject(0)).isReadOnly());
+ assertEquals(LocalDate.of(2024, 5, 17),
batch.column(4).getObject(0));
+ assertEquals(Instant.ofEpochMilli(1_715_000_000_000L),
batch.column(5).getObject(0));
+ assertEquals(Arrays.asList("a", "b"),
batch.column(6).getObject(0));
+ }
+ }
+
+ @Test
+ public void publishesListValuesTheCallerCannotMutate() throws IOException {
+ List<Object> rowWithNullableElement = new ArrayList<>(row());
+ rowWithNullableElement.set(6, Arrays.asList("a", null));
+ try (ListBatchCursor cursor =
+ new ListBatchCursor(
+ schema(),
+ Collections.singletonList(
+
Collections.singletonList(rowWithNullableElement)))) {
+ assertTrue(cursor.next());
+ @SuppressWarnings("unchecked")
+ List<String> tags = (List<String>)
cursor.batch().column(6).getObject(0);
+
+ assertEquals(Arrays.asList("a", null), tags);
+ assertThrows(UnsupportedOperationException.class, () ->
tags.add("c"));
+ }
+ }
+
+ @Test
+ public void walksEveryBatchOnceAndThenReportsExhaustion() throws
IOException {
+ List<List<Object>> firstBatch = Arrays.asList(row(), row());
+ List<List<Object>> secondBatch = Collections.singletonList(row());
+ ListBatchCursor cursor =
+ new ListBatchCursor(schema(), Arrays.asList(firstBatch,
secondBatch));
+
+ assertTrue(cursor.next());
+ assertEquals(2, cursor.batch().rowCount());
+ assertEquals(2, cursor.batch().column(0).valueCount());
+ assertTrue(cursor.next());
+ assertEquals(1, cursor.batch().rowCount());
+ assertFalse(cursor.next());
+ assertFalse(cursor.next());
+ cursor.close();
+ }
+
+ @Test
+ public void refusesToPublishABatchBeforeTheFirstAdvance() throws
IOException {
+ try (ListBatchCursor cursor =
+ new ListBatchCursor(
+ schema(),
Collections.singletonList(Collections.singletonList(row())))) {
+ assertThrows(IllegalStateException.class, cursor::batch);
+ }
+ }
+
+ @Test
+ public void reportsTheSharedSchemaOnEveryBatch() throws IOException {
+ Schema schema = schema();
+ try (ListBatchCursor cursor =
+ new ListBatchCursor(
+ schema,
+ Arrays.asList(
+ Collections.singletonList(row()),
+ Collections.singletonList(row())))) {
+ assertTrue(cursor.next());
+ assertEquals(schema, cursor.batch().schema());
+ assertTrue(cursor.next());
+ assertEquals(schema, cursor.batch().schema());
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/SchemaTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/SchemaTest.java
new file mode 100644
index 00000000..8cdab471
--- /dev/null
+++ b/maven-projects/io-api/src/test/java/org/apache/graphar/io/SchemaTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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 static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+public class SchemaTest {
+
+ private static Field field(String name) {
+ return new Field(name, ColumnType.of(ColumnType.Kind.INT64), false);
+ }
+
+ @Test
+ public void keepsFieldsInPhysicalColumnOrder() {
+ Schema schema = new Schema(Arrays.asList(field("src"), field("dst"),
field("weight")));
+
+ assertEquals(3, schema.fields().size());
+ assertEquals("src", schema.fields().get(0).name());
+ assertEquals("dst", schema.fields().get(1).name());
+ assertEquals("weight", schema.fields().get(2).name());
+ }
+
+ @Test
+ public void acceptsASchemaWithoutColumns() {
+ assertTrue(new Schema(Collections.emptyList()).fields().isEmpty());
+ }
+
+ @Test
+ public void preservesDuplicateColumnNamesForIndexBasedAccess() {
+ Schema schema = new Schema(Arrays.asList(field("src"), field("src")));
+
+ assertEquals(2, schema.fields().size());
+ assertEquals("src", schema.fields().get(0).name());
+ assertEquals("src", schema.fields().get(1).name());
+ }
+
+ @Test
+ public void refusesAMissingFieldListOrField() {
+ assertThrows(NullPointerException.class, () -> new Schema(null));
+ assertThrows(
+ NullPointerException.class, () -> new
Schema(Arrays.asList(field("src"), null)));
+ }
+
+ @Test
+ public void copiesTheSuppliedListSoLaterMutationCannotReachIt() {
+ List<Field> supplied = new ArrayList<>();
+ supplied.add(field("src"));
+ Schema schema = new Schema(supplied);
+
+ supplied.add(field("dst"));
+
+ assertEquals(1, schema.fields().size());
+ }
+
+ @Test
+ public void publishesAnUnmodifiableFieldList() {
+ Schema schema = new Schema(Collections.singletonList(field("src")));
+
+ assertThrows(UnsupportedOperationException.class, () ->
schema.fields().add(field("dst")));
+ }
+
+ @Test
+ public void comparesEqualByPhysicalFieldOrder() {
+ Schema first = new Schema(Arrays.asList(field("src"), field("dst")));
+ Schema same = new Schema(Arrays.asList(field("src"), field("dst")));
+ Schema differentOrder = new Schema(Arrays.asList(field("dst"),
field("src")));
+
+ assertEquals(first, same);
+ assertEquals(first.hashCode(), same.hashCode());
+ assertNotEquals(first, differentOrder);
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/VectorRecordBatchTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/VectorRecordBatchTest.java
new file mode 100644
index 00000000..7c9ed593
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/VectorRecordBatchTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+public class VectorRecordBatchTest {
+ private static final Field IDS = new Field("id",
ColumnType.of(ColumnType.Kind.INT64), false);
+ private static final Field LABELS =
+ new Field("label", ColumnType.of(ColumnType.Kind.STRING), true);
+
+ @Test
+ public void retainsVectorsInSchemaOrder() {
+ Schema schema = new Schema(List.of(IDS, LABELS));
+ ValueVector ids = new TestVector(IDS, List.of(7L, 8L));
+ ValueVector labels = new TestVector(LABELS, Arrays.asList("a", null));
+
+ RecordBatch batch = new VectorRecordBatch(schema, List.of(ids,
labels), 2);
+
+ assertEquals(2, batch.rowCount());
+ assertEquals(2, batch.columnCount());
+ assertSame(ids, batch.column(0));
+ assertSame(labels, batch.column(1));
+ assertEquals("a", batch.column(1).getObject(0));
+ assertEquals(true, batch.column(1).isNull(1));
+ }
+
+ @Test
+ public void rejectsStructuralMismatchesBeforePublishingTheBatch() {
+ Schema schema = new Schema(List.of(IDS));
+ ValueVector oneValue = new TestVector(IDS, List.of(7L));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new VectorRecordBatch(schema, List.of(oneValue), 2));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new VectorRecordBatch(schema, List.of(oneValue,
oneValue), 1));
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ new VectorRecordBatch(
+ schema, List.of(new TestVector(LABELS,
List.of("wrong"))), 1));
+ }
+
+ private static final class TestVector implements ValueVector {
+ private final Field field;
+ private final List<Object> values;
+
+ private TestVector(Field field, List<Object> values) {
+ this.field = field;
+ this.values = 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);
+ }
+ }
+}
diff --git
a/maven-projects/io-api/src/test/java/org/apache/graphar/io/WriteRequestTest.java
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/WriteRequestTest.java
new file mode 100644
index 00000000..06e89e47
--- /dev/null
+++
b/maven-projects/io-api/src/test/java/org/apache/graphar/io/WriteRequestTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.assertSame;
+import static org.junit.Assert.assertThrows;
+
+import java.net.URI;
+import java.util.List;
+import org.junit.Test;
+
+public class WriteRequestTest {
+ @Test
+ public void bindsOutputIdentitySchemaAndDisposition() {
+ Schema schema =
+ new Schema(
+ List.of(
+ new Field("event_date",
ColumnType.of(ColumnType.Kind.DATE), false),
+ new Field(
+ "seen_at",
+
ColumnType.of(ColumnType.Kind.TIMESTAMP_MILLIS),
+ true)));
+ WriteRequest request =
+ new WriteRequest(URI.create("memory:/out"), schema,
WriteMode.CREATE_NEW);
+
+ assertEquals(URI.create("memory:/out"), request.uri());
+ assertSame(schema, request.schema());
+ assertEquals(WriteMode.CREATE_NEW, request.mode());
+ assertEquals(WriteMode.APPEND, WriteMode.valueOf("APPEND"));
+ }
+
+ @Test
+ public void rejectsMissingRequiredWriteContractValues() {
+ Schema schema = new Schema(List.of());
+
+ assertThrows(
+ NullPointerException.class,
+ () -> new WriteRequest(null, schema, WriteMode.OVERWRITE));
+ assertThrows(
+ NullPointerException.class,
+ () -> new WriteRequest(URI.create("memory:/out"), null,
WriteMode.OVERWRITE));
+ assertThrows(
+ NullPointerException.class,
+ () -> new WriteRequest(URI.create("memory:/out"), schema,
null));
+ }
+
+ @Test
+ public void comparesEqualByUriSchemaAndDisposition() {
+ Schema schema =
+ new Schema(List.of(new Field("id",
ColumnType.of(ColumnType.Kind.INT64), false)));
+ WriteRequest first = new WriteRequest(URI.create("memory:/out"),
schema, WriteMode.APPEND);
+ WriteRequest same =
+ new WriteRequest(
+ URI.create("memory:/out"),
+ new Schema(
+ List.of(
+ new Field(
+ "id",
+
ColumnType.of(ColumnType.Kind.INT64),
+ false))),
+ WriteMode.APPEND);
+ WriteRequest differentMode =
+ new WriteRequest(URI.create("memory:/out"), schema,
WriteMode.OVERWRITE);
+
+ assertEquals(first, same);
+ assertEquals(first.hashCode(), same.hashCode());
+ assertNotEquals(first, differentMode);
+ }
+}
diff --git a/maven-projects/pom.xml b/maven-projects/pom.xml
index 81df9024..56b5cd87 100644
--- a/maven-projects/pom.xml
+++ b/maven-projects/pom.xml
@@ -80,6 +80,7 @@
<module>info</module>
<module>storage-api</module>
<module>storage-local</module>
+ <module>io-api</module>
</modules>
<build>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]