-
Notifications
You must be signed in to change notification settings - Fork 1
Indexing and Queries
What this page covers: declaring secondary indexes — with @Indexed on fields or manual
IndexHint factories — and reading through them with findBy and the composable Query API
(eq / range / in / and). Indexes are declared, not implicit: a field is queryable only
after you say so, and querying an undeclared field throws on every backend. Plain key/CRUD reads
are on CRUD Operations.
import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;
import br.com.finalcraft.everydatabase.query.*;
// Declare two indexes on the builder, then query them.
EntityDescriptor<UUID, TestPlayer> PLAYERS = EntityDescriptor.builder(UUID.class, TestPlayer.class)
.collection("players")
.keyExtractor(TestPlayer::getUuid)
.codec(new JacksonJsonCodec<>(TestPlayer.class))
.index(IndexHint.string("name"))
.index(IndexHint.integer("score"))
.build();
Repository<UUID, TestPlayer> repo = storage.repository(PLAYERS);
// Shorthand equality on an indexed field:
List<TestPlayer> alices = repo.findBy("name", "Alice").join();
// Composable query — conditions are AND-joined:
List<TestPlayer> top = repo.query(
Query.eq("name", "Alice").and(Query.range("score", 1000, null))).join();📌 Note —
findByandqueryreturnCompletableFuture<List<V>>; the examples use.join()for brevity. There are no blocking variants — compose withthenApply/thenComposein real code. See The Async API.
TestPlayer mirrors the real test entity
(core/src/test/java/br/com/finalcraft/everydatabase/data/TestPlayer.java): uuid / name /
score / world / active / createdAt.
A field becomes queryable in exactly one of two ways. They can be combined on the same entity.
Annotate fields; EntityDescriptor.build() scans the class hierarchy and creates the matching
IndexHint for each. Mirrors AnnotatedTestPlayer
(core/src/test/java/.../data/AnnotatedTestPlayer.java):
public class AnnotatedTestPlayer {
private UUID uuid;
@Indexed
private String name; // FieldType auto-detected: STRING
@Indexed
private int score; // INT, ascending (default)
@Indexed(path = "rank.title", type = String.class) // nested dot-path
private Rank rank;
@Indexed(path = "location.world", type = String.class) // nested dot-path
private Location location;
private List<Badge> badges; // not annotated — stored, not indexed
}@Indexed has three members:
| Member | Default | Meaning |
|---|---|---|
path() |
"" → the field's name |
the index field path; use dot-notation for nested fields |
type() |
void.class → auto-detect |
override the Java type the FieldType is resolved from |
order() |
ASCENDING |
sort order of the backing index |
The FieldType is inferred from the field's declared Java type: String → STRING,
int/Integer → INT, long/Long → LONG, float/double (+ boxed) → DOUBLE,
boolean/Boolean → BOOLEAN, Instant/LocalDateTime → TIMESTAMP.
⚠️ Gotcha — for a nestedpath(e.g."location.world"), the annotated field's own Java type (Location) doesn't match the indexed value's type (String), so the scanner can't infer it. You must settypeexplicitly. An annotated field whose type can't be mapped and has no explicittypethrowsIllegalArgumentExceptionatbuild().
When you can't (or don't want to) annotate the class, declare hints on the builder. Mirrors
TestPlayer, which declares its hints manually:
EntityDescriptor<UUID, TestPlayer> PLAYERS = EntityDescriptor.builder(UUID.class, TestPlayer.class)
.collection("players")
.keyExtractor(TestPlayer::getUuid)
.codec(new JacksonJsonCodec<>(TestPlayer.class))
.index(IndexHint.string("name"))
.index(IndexHint.integer("score"))
.index(IndexHint.bool("active"))
.index(IndexHint.timestamp("createdAt"))
.build();| Factory | FieldType |
SQL column type | Java field type |
|---|---|---|---|
IndexHint.string(path) |
STRING |
TEXT |
String |
IndexHint.integer(path) |
INT |
INT |
int / Integer
|
IndexHint.bigInt(path) |
LONG |
BIGINT |
long / Long
|
IndexHint.decimal(path) |
DOUBLE |
DOUBLE |
double / float
|
IndexHint.bool(path) |
BOOLEAN |
BOOLEAN |
boolean / Boolean
|
IndexHint.timestamp(path) |
TIMESTAMP |
DATETIME(3) / TIMESTAMPTZ
|
long / Instant / LocalDateTime
|
Modifiers return a new immutable hint: IndexHint.string("name").asDescending() (or .asAscending()).
⚠️ Gotcha — the factory names don't match the type names.bigInt(...)produces aLONGindex (for Javalong), anddecimal(...)produces aDOUBLEindex (for Javadouble). There is noIndexHint.long(...)(longis a reserved word) and noIndexHint.double(...). Reach forbigIntwhenever the field is along, anddecimalwhenever it's adouble/float.
You can mix manual .index(...) calls with @Indexed annotations on the same entity. But if the
same field path appears in both, build() throws (IllegalStateException, duplicate index
hint) — declare each path exactly once.
📌 Note — indexes are reconciled automatically. Add a new hint to an existing collection and the backend
ALTERs the column/index in and backfills it from existing rows the next time the repository is opened; remove a hint and the backing index is dropped. No manual migration needed (that's separate frommigrate()— see Schema Migrations).
CompletableFuture<List<V>> findBy(String fieldPath, Object value);repo.findBy("name", "Alice") is exactly repo.query(Query.eq("name", "Alice")), just shorter for
the common single-equality case.
CompletableFuture<List<V>> query(Query query);Query is built from static factories and composed with .and(...):
// Equality
repo.query(Query.eq("world", "world_nether")).join();
// Range — inclusive on both ends; null = open end
repo.query(Query.range("score", 100, 500)).join(); // 100 <= score <= 500
repo.query(Query.range("score", 100, null)).join(); // score >= 100 (open upper)
repo.query(Query.range("score", null, 500)).join(); // score <= 500 (open lower)
// IN-list (Collection or varargs)
repo.query(Query.in("name", Arrays.asList("Alice", "Bob"))).join();
repo.query(Query.in("name", "Alice", "Bob")).join(); // varargs convenience
// AND of several conditions
repo.query(Query.eq("world", "world")
.and(Query.range("score", 1000, null))).join(); // world == "world" AND score >= 1000The operators:
| Factory | Operator | Semantics |
|---|---|---|
Query.eq(path, value) |
EQ |
path = value |
Query.range(path, from, to) |
RANGE |
from <= path <= to; inclusive; null = open end |
Query.in(path, values) / (path, varargs)
|
IN |
path is one of values
|
q.and(other) |
— | concatenates conditions, all joined by AND
|
⚠️ Gotcha — AND only, no native OR.Query.and(...)is the only combinator, and conditions are always intersected. For OR, run two queries and union the results client-side:var a = repo.query(Query.eq("world", "world")).join(); var b = repo.query(Query.eq("world", "world_nether")).join(); // union by key (dedupe) on your side
A timestamp index accepts Instant or LocalDateTime in queries, and the entity field itself may
be a Java long (epoch millis), an Instant, or a LocalDateTime. Every backend stores and
compares the value as epoch-milliseconds — SQL just uses a native date column type so values stay
human-readable in DB tools; InMemory and LocalFile keep a Long internally.
// Declared with .index(IndexHint.timestamp("createdAt"))
repo.query(Query.range("createdAt",
Instant.now().minus(7, ChronoUnit.DAYS), Instant.now())).join(); // last 7 days
repo.query(Query.range("createdAt", someInstant, null)).join(); // after someInstant
repo.query(Query.range("createdAt", null, someInstant)).join(); // before someInstant💡 Tip — because comparison is epoch-millis under the hood, a
long-typedcreatedAtfield and anInstant-typed one are interchangeable for querying; pick whatever your entity already uses.
A query (via findBy or query) against a field that was not declared as an index throws
IllegalArgumentException at execution time. This holds on every backend, including LocalFile —
which has no real index and answers declared-field queries with a correct-but-slow full scan, yet
still validates the declaration first and rejects undeclared fields.
// 'mood' was never declared as an index:
repo.findBy("mood", "happy"); // future completes exceptionally with IllegalArgumentException🧭 Decision — why LocalFile rejects fields it could scan. It would be technically possible for LocalFile to brute-force any field. It deliberately doesn't, so that a query which works on LocalFile keeps working unchanged when you swap the backend for SQL or Mongo. Validate-then-scan keeps behavior identical across Choosing a Backend; you never write a query that silently only works on one of them.
How each backend executes a valid query:
-
SQL (MySQL/MariaDB/PostgreSQL/H2) — a stored
_idx_<field>column populated at save time, with a real B-tree index; conditions becomeWHERE … = ? / BETWEEN / INjoined byAND. -
MongoDB — value mirrored into
_idx_<field>withcreateIndex;Filters.and(...). -
InMemory — a
Map<value, Set<key>>per indexed field; intersection of key sets. -
LocalFile — no real index: full scan + tree-walk filter (
O(n)per call), after validating the field is declared.
-
CRUD Operations — key-based reads/writes (
find,save,delete, …). -
Defining Entities — the
EntityDescriptor.builderwhere indexes are declared. -
The Async API —
findBy/queryreturn futures; composition and error model. - Choosing a Backend — how each backend materializes an index (and the LocalFile full-scan).
-
Schema Migrations — index reconciliation vs. explicit
migrate(). -
Gotchas & Pitfalls — the
bigInt→LONG /decimal→DOUBLE naming trap, AND-only queries, the undeclared-field rejection, in one place.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing