Columnar Projection Store generates an append-only, in-memory columnar store for a Java interface. Annotate a projection schema, compile it with the separate annotation processor, add source projections, seal the store, and read rows through stable indexed views or allocation-conscious cursors.
Primitive-valued columns use primitive arrays. Reference-valued columns use
arrays of the accessors' erased return types and keep references, including
null; referenced objects are not copied or flattened. The runtime
abstractions and generic reflective factory live in
io.github.jutil.columnarprojection. The processor generates a public
schema-specific <Projection>Store contract and the supported concrete store
constructor into the schema package; all other generated implementation details
are unsupported. The library has no runtime dependencies and targets Java 8.
Version 1.2.0 is the latest release and provides the generated, type-safe
batch API for appending column arrays while preserving the row-oriented API and
storage semantics introduced in 1.0.0. Development version
1.3.0-SNAPSHOT adds generated synchronous methods for progressively filling
individual columns; distinct columns may be filled concurrently.
The installation example below therefore keeps the published 1.2.0
coordinate; per-column filling is development API until a 1.3.0 release is
available.
Maven Central listings:
runtime API
and
annotation processor.
Javadocs: runtime API and annotation processor.
The build produces two JARs. Runtime users depend only on
io.github.j-util:columnar-projection-store; configure
io.github.j-util:columnar-projection-store-processor only on the annotation
processor path. The processor is not required on the runtime classpath. This
Maven 3 / Maven Compiler Plugin 3.x configuration makes that boundary and
processor execution explicit:
<properties>
<maven.compiler.release>8</maven.compiler.release>
<columnar-projection-store.version>1.2.0</columnar-projection-store.version>
</properties>
<dependencies>
<dependency>
<groupId>io.github.j-util</groupId>
<artifactId>columnar-projection-store</artifactId>
<version>${columnar-projection-store.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.github.j-util</groupId>
<artifactId>columnar-projection-store-processor</artifactId>
<version>${columnar-projection-store.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>Explicit processor configuration is important on modern JDKs: beginning with
JDK 23, javac no longer discovers and runs processors merely because they are
on the ordinary compile class path. annotationProcessorPaths also limits
processor discovery to the dependencies intentionally listed there.
package example;
import io.github.jutil.columnarprojection.ProjectionCursor;
import io.github.jutil.columnarprojection.ProjectionSchema;
import io.github.jutil.columnarprojection.ProjectionStore;
import io.github.jutil.columnarprojection.ProjectionStores;
public final class OrderExample {
@ProjectionSchema
public interface OrderProjection {
long id();
String customer();
int itemCount();
default boolean isLarge() {
return itemCount() >= 10;
}
}
private static final class Order implements OrderProjection {
private final long id;
private final String customer;
private final int itemCount;
private Order(long id, String customer, int itemCount) {
this.id = id;
this.customer = customer;
this.itemCount = itemCount;
}
@Override
public long id() {
return id;
}
@Override
public String customer() {
return customer;
}
@Override
public int itemCount() {
return itemCount;
}
}
public static void main(String[] args) {
ProjectionStore<OrderProjection> store =
ProjectionStores.create(OrderProjection.class, 2);
store.add(new Order(1001L, "Ada", 3));
store.add(new Order(1002L, null, 12));
store.seal();
ProjectionCursor<OrderProjection> cursor = store.cursor();
while (cursor.moveNext()) {
// This object is reused and is valid only at the current row.
OrderProjection row = cursor.current();
System.out.println(row.id() + " " + row.customer()
+ " large=" + row.isLarge());
}
// Unlike cursor.current(), an indexed view is stable and retainable.
OrderProjection first = store.viewAt(0);
System.out.println("first=" + first.id());
}
}For schema-specific use, prefer the generated <Projection>Store interface.
Its static create(int) method directly constructs the generated concrete
implementation known at compile time while exposing typed batch operations and
a nested typed column-appender contract.
Because the contract and implementation are generated into the schema package,
this path works in a named module even when that package is neither exported nor
opened.
Per-column filling does not replace or change create(int), the generated
concrete store's public constructor, ProjectionStores.create, or the generated
batch interface. The common ProjectionStore<T> interface intentionally does
not expose the column appender.
ProjectionStores.create(Projection.class, ...) instead performs runtime
reflective discovery for schema-agnostic and row-oriented code. It returns the
common ProjectionStore<T> contract, whose static type does not expose
schema-specific batch setters or columnAppender(). Using var does not change
that: local-variable type inference uses the declared factory return type and
cannot derive a generated interface from Class<T>. If the schema is in a
named module, this generic reflective factory requires the module to export the
schema package or open it to the runtime module, for example:
opens hidden to columnar.projection.store;No export or open is required by the generated schema-specific factory.
The generated concrete store's public constructor remains compatible for
direct construction, but it is no longer necessary for typed batching. Runtime
abstractions remain in io.github.jutil.columnarprojection. Other generated
implementation details, such as backing fields, private batch implementations,
the private column-appender implementation, and view classes, are not supported
API.
For a top-level projection named PriceProjection, the processor generates the
public PriceProjectionStore contract and the compatible concrete
PriceProjection__ColumnarProjectionStore. The contract's store-specific batch
accepts exactly the array type corresponding to each projection accessor.
Whole-array mode copies every element:
@ProjectionSchema
public interface PriceProjection {
double price();
String symbol();
}
PriceProjectionStore prices = PriceProjectionStore.create(expectedSize);
prices.batch()
.price(new double[] {15.1, 25.2})
.symbol(new String[] {"A", "B"})
.append();For pipelines that already produce columns independently, the generated store also declares a nested typed appender. It has two synchronous methods with each projection accessor's exact name. The whole-array overload appends every element; the range overload appends the half-open source range. Accessor-named filling methods are not declared directly on the store:
@ProjectionSchema
interface Trade {
long timestamp();
double price();
String symbol();
}
TradeStore trades = TradeStore.create(expectedSize);
TradeStore.ColumnAppender columns = trades.columnAppender();
columns.timestamp(timestampChunk);
columns.timestamp(moreTimestamps, fromIndex, toIndex);
columns.price(priceChunk);
columns.price(morePrices, differentFromIndex, differentToIndex);
columns.symbol(symbolChunk);
columns.symbol(moreSymbols);
// Join any caller-managed filling tasks before sealing.
trades.seal();The official generated store returns the same store-owned appender object from
every columnAppender() call. Each appender method copies synchronously and
retains no source-array reference, so the caller may reuse or mutate the source
after successful return. The selected source elements must remain stable until
the call returns. Chunk boundaries may differ between columns. Each column owns
its append count and grows only its own backing array. Calls for distinct
columns may execute concurrently; calls to the same column require external
single-writer serialization. No column call waits for another column.
columnAppender() is a default method on the generated store contract. This
adds no new abstract implementation obligation. An ordinary implementation or
decorator written against the generated 1.2.0 contract, with no conflicting
zero-argument columnAppender() method, inherits a default that throws
UnsupportedOperationException; the tested precompiled 1.2.0 implementation
and consumer remain binary compatible. Source recompilation may require changes
when an earlier type already declares columnAppender() with an incompatible
return type, or inherits a conflicting default. The official
processor-generated implementation overrides the fallback with the appender
described above. The generated concrete store itself does not implement the
appender interface or expose accessor-named filling methods.
During per-column filling, size() remains zero. After the caller has joined
all filling tasks, seal() compares every generated column count. A successful
seal publishes the common count as the logical size. A mismatch throws
IllegalStateException with column/count information, leaves the store
unsealed, preserves all copied values, and permits corrective filling followed
by another seal() call. A sealed multi-column store always requires equal
column counts. Missing columns are not inferred as null, zero, or any other
default. An untouched store still seals with size zero.
Common-range mode applies one half-open source range to every column:
prices.batch(sourceFromIndex, sourceToIndex)
.price(priceValues)
.symbol(symbols)
.append();Generated top-level names use the projection's binary name. Consequently, a
member schema named Outer.PriceProjection generates the top-level contract
Outer$PriceProjectionStore, not PriceProjectionStore nested inside Outer.
sourceFromIndex is inclusive and sourceToIndex is exclusive. They are
indexes into each supplied source array, never indexes into the store. Both
modes always append at the store's current size when append() executes; they
cannot address, replace, or overwrite existing rows.
The returned nested type is ordinarily named Batch. If that name would
shadow the first identifier of a type reference required by generated source,
the processor chooses a deterministic collision-safe name by appending
underscores. This covers named-package roots observable through the bounded
inputs described below and unnamed-package top-level types. Chained use through
either batch factory, as above, does not require callers to spell the nested
type name.
The column-appender contract is ordinarily named ColumnAppender. Its nested
contract and private implementation names use the same deterministic
collision-safe underscore rule as the batch types, so neither name shadows a
source type or package required by generated signatures. Chained use through
columnAppender() avoids depending on the selected nested type name.
The generated store contract ordinarily uses the projection's binary simple
name followed by Store. If that candidate is a package observable through the
inputs described below or would shadow a source type root needed by the
contract, the processor appends underscores until neither conflict remains. For
example, a schema named example.Schema generates example.SchemaStore_ when
the package example.SchemaStore is observable, and example.SchemaStore__
when both example.SchemaStore and example.SchemaStore_ are observable. If
the selected generated contract or the fixed concrete implementation name is
already declared by user code, compilation fails with a generated-name
collision diagnostic; the processor does not rename around, overwrite, or
silently skip a user type.
Package detection uses only Java 8-compatible standard annotation-processing and language-model APIs; the processor does not scan arbitrary class-path directories or use compiler internals. It supports ordinary class-path compilation and one named or unnamed source module per compiler invocation. Maven reactor modules remain supported when Maven invokes the compiler separately for each module. If an invocation that contains a projection schema has roots from more than one source module, compilation fails with a diagnostic requiring separate compilation. That check occurs before schema preparation, name selection, or generated-file creation, so it leaves no partial generated output.
Before selecting any generated name in a supported compilation, the processor
records every ancestor prefix of every package represented by a current type or
package root. For example, a root declared in example.SchemaStore.sub records
example, example.SchemaStore, and example.SchemaStore.sub. For a single
named source module, the module root is not treated as a source type:
annotations directly present on it are inspected, and directly enclosed
PackageElement instances contribute package prefixes. Package members are not
recursively enumerated, and class-path types are not reclassified as current
source. Collection is independent of source-file and root-element order.
The processor cycle-safely examines current top-level and nested declarations: types, fields and enum constants, methods and constructors, parameters and type parameters, and record components when the running compiler supports them. It also examines package-info declarations and annotations directly present on the module declaration. Declaration-signature traversal covers superclasses and interfaces, field types, method returns, constructor and method parameters, executable receiver types, thrown types, and type-parameter bounds. It recursively follows arrays, generic arguments, wildcards, enclosing types, type variables, intersections, and declared or unresolved error types. Resolved return types of effective projection accessors, including inherited accessors, are also included.
Annotations on each such declaration and on every recursively visited
TypeMirror are included. Traversal follows each annotation's declared type,
explicitly supplied values, class-literal types, enum constants, nested
annotations, and annotation-value arrays. It also follows default values
declared by annotation members that are themselves current source. Arbitrary
members and default values of external annotation types are not inspected.
Type-use annotations can therefore affect collision-safe naming, but they
remain intentionally omitted from generated source-array signatures; the
projection interface remains authoritative for them.
The processor cannot observe external types referenced only by module
uses/provides directives, imports without a declaration-signature use,
method or constructor bodies, initializer or anonymous-class bodies, genuinely
unreferenced descendant class-path packages, or packages first created by
another annotation processor in a later round after names have been selected
and emitted. If a candidate parent package would otherwise be invisible, place
package-info.java in that candidate parent package and make it available in
the schema's naming round, either as current source or through its
package-info.class. This makes the parent prefix observable for naming.
The provenance marker used to recognize processor-owned output exists only in output generated by the 1.2 processor. When upgrading from a pre-1.2 processor, remove the old class output and generated-source output and perform one clean build. An unmarked generated implementation left by an earlier processor cannot be distinguished safely from an external user type.
After that one-time clean build, the processor recognizes its own 1.2-generated contract and concrete implementation from a previous compilation. An unchanged schema, or a schema with changed accessors but unchanged generated top-level names, can therefore be compiled again with the previous class output on the class path without a clean build. Current source declarations, external class-path types, and cross-schema conflicts remain collisions. If schema evolution selects a different generated top-level name and stale output cannot be removed safely, compilation reports the specific stale name and requests a clean rebuild.
For a parameterized accessor, its batch setter and column-appender methods
preserve the resolved declared return type whenever every part of that type can
legally be named from the generated store. For example,
List<String> labels() produces source parameters accepting List<String>[];
an ArrayList<String>[] is compatible while an ArrayList<Integer>[] is
rejected at compilation. Backing columns still use erased, reifiable arrays
such as List[]. If a generic argument is inaccessible to the generated
top-level class, the source parameter falls back to the erased array type. This
fallback preserves schemas accepted by earlier versions, but necessarily loses
generic argument checking for that column. Generated source-array signatures
preserve source-nameable Java type structure and generic arguments, but
intentionally omit type-use annotations. The projection interface remains
authoritative for type-use annotations.
In whole-array mode, the first non-null array successfully assigned to a
column establishes the row count. Every later column array must have exactly
that length. An unequal array throws IllegalArgumentException before that
column is assigned, so it remains available for correction. Every generated
batch column setter must be called exactly once, even for an empty whole-array
batch.
Consequently, batch().append() fails with IllegalStateException; represent
an empty whole-array batch by supplying an empty array for every column.
In common-range mode, creation requires
0 <= sourceFromIndex <= sourceToIndex; an invalid range throws
IndexOutOfBoundsException. Every supplied array must have length at least
sourceToIndex. Arrays may have different physical lengths as long as each
contains the complete range. A too-short array throws
IndexOutOfBoundsException before that column is assigned, so it remains
available for correction. A positive range requires every generated column
method exactly once. An empty explicit range may be appended as a no-op without
any column assignments; if a column is supplied, it is still validated against
sourceToIndex.
In both batch modes, a column rejects null with NullPointerException, and a
second successful assignment to the same column throws
IllegalStateException. Missing-column failures leave the unfinished batch
available for correction and retry.
An unfinished batch retains its source arrays but does not copy them until
append() executes. Mutations to selected source-array elements before
append() are therefore visible. A successful positive append reserves
destination capacity once, performs one System.arraycopy from each column,
and increases the logical size only after all column copies complete. It does
not modify the source arrays and releases its references to them after success.
Later replacement or mutation of source-array elements does not change the
stored values. Reference-valued elements still use the store's shallow-copy
semantics: the references are copied, while the referenced objects, including
array-valued projection results, are not cloned.
Column-appender methods have different ownership: they validate and copy during the method call and never retain the source array. Selected source elements must remain stable until the call returns. On successful return, replacing or mutating source-array elements cannot change stored column values. As with batch append, reference elements are shallow-copied; mutating an object already referenced by both source and store remains visible through that object.
A null column-appender source throws NullPointerException while the store is
building. Range overloads require
0 <= sourceFromIndex <= sourceToIndex <= source.length and validate the range
before selecting a mutation mode or changing capacity. Null elements in
reference columns are valid. An all-null reference array contributes its full
length. Primitive columns have no null representation; boxed accessor types are
reference columns and may contain null elements. Empty arrays and empty ranges
are valid no-ops.
The destination starts at the store's size when append() executes, not when
the batch is created. Consequently, multiple unfinished whole-array and ranged
batches may be appended in any order, and their successful append calls
interleave with add in execution order. Ordinary validation happens before
the logical size changes. After a successful append, including an empty-range
no-op, the batch is consumed and cannot be reused.
batch and append are building-state operations. Creating a batch on a
sealed store fails, and sealing after batch creation makes append() fail
before any column is copied. Batch construction, column assignment, append,
add, and seal are not thread-safe and must not be called concurrently.
A new store is logically empty and in its building state. expectedSize is an
initial-capacity hint, not a row limit; zero is valid and the store grows when
necessary.
- While building, use either row mode (
addand batch append) or per-column mode. CallingcursororviewAtbefore sealing throwsIllegalStateException. sealpermanently moves the store to its read state after validating column counts. Sealing an untouched empty store is valid, and repeated successful calls are harmless. An unequal-count failure leaves the store building and can be retried after corrective filling.- After sealing,
size,cursor, andviewAtare available.addthrowsIllegalStateException, as do all generated column-appender methods; a store cannot be unsealed.
The first positive add or batch append selects row mode. The first positive
per-column call selects column mode. Positive operations from the other mode are
rejected before store mutation. Empty no-ops and validation failures do not
select a mode. Existing add and batch append operations remain mixable with
each other in row mode.
In column mode, the store intentionally exposes no partially aligned logical
rows: size() stays zero until a successful seal. seal() never waits for
column filling and must not run concurrently with it. The caller is responsible
for joining all filling tasks first. If counts differ, the exception reports a
mismatched column pair and their counts; no values are discarded, and missing
columns are never synthesized from null or primitive defaults.
add immediately evaluates the projection accessors and copies their results;
it never retains the source projection. Each accessor reached during an add
attempt is invoked exactly once. Evaluation order is unspecified, and if an
accessor throws, evaluation stops, the same exception is propagated, and no
logical row is appended. Side effects from accessors already invoked are not
rolled back. Primitive results are copied by value. Reference results,
including null, are copied as references without a deep copy. The source
projection must not be mutated concurrently while it is being read.
While building, passing a null projection to add throws
NullPointerException. Every generated factory and
ProjectionStores.create rejects a negative expectedSize with
IllegalArgumentException. For ProjectionStores.create, a null projection
type throws NullPointerException, and a non-interface projection type produces
IllegalArgumentException. A missing generated implementation produces
IllegalStateException whose message explains that the processor artifact must
be configured on the annotation-processor path; the original class-loading
failure is retained as the cause. Malformed or incompatible generated code and
any failure to instantiate it also produce IllegalStateException with the
underlying cause where one is available. If reflective constructor access is
denied, the message explains the named-module export/open requirement and the
IllegalAccessException remains the cause.
viewAt(index) returns a stable, read-only projection permanently bound to one
row. It throws IndexOutOfBoundsException for an invalid index after the store
has been sealed. Calls are not guaranteed to return the same object for the same
index, so callers must not depend on view identity or caching.
A new cursor starts before the first row. moveNext() positions it and returns
true, or returns false when exhausted. Once exhausted it remains exhausted
until rewind() is called. current() is valid only after a successful
moveNext() and before exhaustion or rewind; otherwise it throws
IllegalStateException.
Each cursor owns one reusable projection view. current() returns that same
view as the cursor advances, so do not retain it or compare its identity across
rows. Use viewAt when a row projection must outlive the current cursor
position. Cursors own no external resources and are not AutoCloseable.
The annotation processor rejects an invalid schema during compilation. A valid schema follows these rules:
- The annotated type is a non-generic interface declared at top level or as a member interface. It and each enclosing type must not be private.
- Each effective abstract instance method defines one column. Inherited accessors are included; overridden or compatible covariant declarations are resolved to their effective accessor.
- An accessor has no parameters or method type parameters, returns a non-void value, and declares no checked exceptions.
- The erased accessor return type is accessible to a generated top-level class in the schema's package. Primitive and reference return types are supported.
- Static methods are not columns. Default methods provide behavior and are not stored columns. Conflicting inherited abstract/default declarations or incompatible inherited return types are rejected.
- The schema declares or inherits at least one effective abstract accessor. Schemas with no methods or only default/static methods are rejected.
- Abstract declarations that conflict with
java.lang.Objectmethods, such astoString(),hashCode(), orequals(Object), are rejected as columns.
The schema interface itself cannot declare type parameters. Fully parameterized generic parent interfaces are allowed; raw generic parents are rejected, and inherited accessors are resolved in the annotated schema's type context.
Let c be the number of stored accessors, r the current row count, n the
number of selected source values, capacity a backing-array length, and
newCapacity its length after a growth step. The bounds below describe store
overhead; they exclude work performed inside user-written accessor bodies and
one-time JVM class loading.
| Operation | Time |
|---|---|
Generated <Projection>Store.create or ProjectionStores.create |
O(c * (capacity + 1)) for the requested initial capacity |
add |
Amortized O(c); a growth step is O(c * newCapacity) |
Generated batch construction |
O(c) |
| A batch column assignment | O(1) |
Batch append |
Without growth, O(c * (n + 1)); with growth, O(c * (newCapacity + n + 1)) |
columnAppender() |
O(1) |
| One column-appender whole-array or range call | Without growth, O(n); with growth, O(newCapacity + n) for that column only |
seal |
O(c) count validation |
size, cursor, viewAt |
O(1) |
Cursor moveNext, current, rewind |
O(1) |
| A generated projection accessor | O(1) |
In row mode, retained column storage is O(c * capacity) array slots. Shared
row/batch growth can temporarily require another O(c * newCapacity) slots
while existing columns are copied. At the peak, both generations can be live,
for O(c * (capacity + newCapacity)) backing-array slots. This includes a large
batch that makes newCapacity jump from capacity to at least r + n; its
temporary growth storage is not bounded by O(c * r). expectedSize can avoid
those copies when the eventual row count is known, but it allocates that initial
capacity for every column.
For a positive batch, append time is linear in the total number of copied
values (c * n) in addition to any growth work. An unfinished batch retains
one source-array reference per assigned column until it is successfully
appended. In common-range mode, caller-owned source arrays may be much longer
than n, so the amount of source storage kept reachable is not bounded by the
selected row count.
In column mode, capacities may differ. Retained backing storage is the sum of the individual column capacities. A growth step temporarily retains only that column's old array and new array and never grows, copies, or replaces another column. A column-appender call uses constant additional bookkeeping beyond any new backing array allocated for growth, and it retains no caller source after successful return.
Calls to generated column-appender methods for distinct columns may execute concurrently. Each column is single-writer: calls targeting the same column must be externally serialized. A column-appender call never waits for another column and does not lock or grow another column on its normal copy path.
Do not invoke add, either batch factory, batch column methods, batch
append, size, seal, cursor, or viewAt concurrently with per-column
filling. Join the caller-managed filling tasks before sealing. seal() performs
only count validation and never waits for filling to finish. Row mutation and
batch mutation remain externally serialized with each other.
After seal() and safe publication to reader threads, the store and stable
indexed views may be read concurrently. Each thread must use its own cursor;
cursors are not thread-safe.
Safe publication applies only to the stored references. The library does not make mutable referenced objects immutable or thread-safe.
- Stores are in-memory, append-then-read structures. V1 has no update, removal, clear, unseal, or persistence API.
- References are opaque and are never recursively flattened, cloned, or serialized.
- Annotation processing is required; there is no reflective or dynamic-proxy storage fallback.
- Runtime abstractions are supported only in
io.github.jutil.columnarprojection. In each schema package, the generated<Projection>Storecontract and concrete store constructor are also supported; all other generated implementation details are unsupported. - JPMS annotation-processing support is limited to the one-source-module-per-compiler-invocation boundary described above. V1 makes no native-image compatibility guarantee.
- Query planning, indexing, filtering, aggregation, schema migration, and durability are outside the V1 scope.
Run the complete local quality gate from the repository root:
./mvnw clean verifyThe project is licensed under the Apache License 2.0.