Skip to content

CRUD Operations

Petrus Pradella edited this page Jul 29, 2026 · 7 revisions

CRUD Operations

What this page covers: the eight methods on Repository<K, V>find / findMany / save / saveAll / delete / exists / count / all — their exact signatures, return shapes, and how to compose them asynchronously. Secondary-index reads (findBy / query) have their own page: Indexing & Queries.


The 30-second version

import br.com.finalcraft.everydatabase.*;
import br.com.finalcraft.everydatabase.codec.JacksonJsonCodec;

import java.util.*;

// A descriptor (key type FIRST, entity type second) — see [[Defining Entities]].
EntityDescriptor<UUID, TestPlayer> PLAYERS = EntityDescriptor.builder(UUID.class, TestPlayer.class)
        .collection("players")
        .keyExtractor(TestPlayer::getUuid)
        .codec(new JacksonJsonCodec<>(TestPlayer.class))
        .build();

Repository<UUID, TestPlayer> repo = storage.repository(PLAYERS);

UUID id = UUID.randomUUID();
repo.save(new TestPlayer(id, "Alice", 100)).join();      // upsert

Optional<TestPlayer> found = repo.find(id).join();        // Optional — empty if absent
boolean exists           = repo.exists(id).join();        // true
long total               = repo.count().join();           // 1
boolean deleted          = repo.delete(id).join();        // true (it existed)

📌 Note — every method here returns a CompletableFuture; there are no blocking variants. The examples call .join() only to stay short. In real code, compose with thenApply / thenCompose / thenAccept instead of blocking — failures surface as the cause of a CompletionException. The whole model is on The Async API.

TestPlayer mirrors the real test entity (core/src/test/java/br/com/finalcraft/everydatabase/data/TestPlayer.java): a @Data @NoArgsConstructor POJO with uuid / name / score. Any Jackson-serializable class with a no-arg constructor works — see Defining Entities and Codecs.


The contract at a glance

Method Signature Returns
find find(K key) CompletableFuture<Optional<V>> — entity or Optional.empty()
findMany findMany(Collection<K> keys) CompletableFuture<List<V>>missing keys omitted
save save(V entity) CompletableFuture<Void> — upsert (insert or replace)
saveAll saveAll(Collection<V> entities) CompletableFuture<Void> — batched upsert
delete delete(K key) CompletableFuture<Boolean>true if it existed
exists exists(K key) CompletableFuture<Boolean>
count count() CompletableFuture<Long> — total stored
all all() CompletableFuture<Stream<V>> — every entity

Plus three members aimed at maintenance passes and cache sync rather than everyday CRUD:

Method Signature Returns
saveAll saveAll(Collection<V> entities, WriteMode mode) CompletableFuture<Void> — batch write under a mode
scanAll scanAll(Cursor cursor, int limit) CompletableFuture<Slice<ScanRow<V>>> — key-ordered page
versions versions(Collection<K> keys) CompletableFuture<Map<K, Long>> — key → lock_version, content-free

The interface lives at core/.../Repository.java. Get one Repository per descriptor from a live Storage; it's cached, so storage.repository(sameDescriptor) hands back the same object.


Reading: find, findMany, exists, count, all

find — one entity, maybe

CompletableFuture<Optional<TestPlayer>> find(UUID key);

A miss is an empty Optional, never a thrown exception. Compose off the future rather than blocking:

repo.find(id)
    .thenApply(opt -> opt.map(TestPlayer::getScore).orElse(0))
    .thenAccept(score -> render(score));

findMany — a bulk read, missing keys silently dropped

CompletableFuture<List<TestPlayer>> findMany(Collection<UUID> keys);

Returns only the entities that exist. The result List can be shorter than the input collection, and the order is not guaranteed to track the input order.

⚠️ Gotcha — because missing keys are omitted, you can't infer "which keys were absent" from the result size alone. If you need that, diff the returned entities' keys against the requested set:

Set<UUID> requested = new HashSet<>(ids);
List<TestPlayer> hits = repo.findMany(ids).join();
hits.forEach(p -> requested.remove(p.getUuid()));
// 'requested' now holds the missing keys

exists and count

CompletableFuture<Boolean> exists(UUID key);
CompletableFuture<Long>    count();

exists is cheaper than find(...).thenApply(Optional::isPresent) — backends answer it without materializing the entity. count() is the total number of stored entities in the collection.

A row whose payload does not decode is counted — it occupies storage, and a later write to its key overwrites it, so it exists. all() and query() skip it instead, which makes count() != all().count() the signal that the collection holds a poisoned row rather than a bug; scanAll is what names it.

⚠️ Gotcha (grouped files only)count() and keys() throw on a key file that cannot be parsed at all, instead of leaving it out. A key file there holds every collection sharing its key, so one that will not parse belongs to no collection: skipping it under-reports the collection being counted, counting it inflates every other collection in the directory. Neither number is true, so the call refuses and names the file. Run scanAll to list every offending file, then repair or remove it. This is not the poisoned row above — that file parses and declares the collection, so it is unambiguously a row of it and still counts. No other backend can reach this state. See Grouped Files.

all — stream every entity

CompletableFuture<Stream<TestPlayer>> all();

The future yields a Stream that backends materialize internally (paginating large datasets); consume it promptly:

long highScorers = repo.all().join()
        .filter(p -> p.getScore() > 1000)
        .count();

💡 Tipall() is a full-collection read. For filtered reads, declare an index and use findBy / query instead so the backend does the work — see Indexing & Queries.

scanAll — paginated maintenance scan that never hides a bad row

CompletableFuture<Slice<ScanRow<TestPlayer>>> scanAll(Cursor cursor, int limit);

Key-ordered, match-all pagination for a full-collection sweep. Unlike all() it doesn't materialise the whole collection; unlike query it needs no declared index — it orders by the storage key itself. Begin with Cursor.scan() and follow Slice.nextCursor() until hasNext() is false:

Cursor cursor = Cursor.scan();
while (true) {
    Slice<ScanRow<TestPlayer>> page = repo.scanAll(cursor, 256).join();
    for (ScanRow<TestPlayer> row : page.content()) {
        if (row.isFailed()) {                       // ALWAYS check this first
            log.warn("undecodable row {}: {}", row.key(), row.error());
            continue;
        }
        process(row.value());
    }
    if (!page.hasNext()) break;
    cursor = page.nextCursor().orElseThrow();
}

The point is ScanRow. Every other read silently drops a row whose stored payload won't decode; scanAll surfaces it (key + cause) so a maintenance pass can count it, name it in a log, and refuse to declare the scan complete instead of mistaking a shorter scan for a finished one.

⚠️ GotchascanAll does not spare you the decode. It still runs every row through the descriptor's codec and hands you a ScanRow — what it avoids is materialising the whole collection and discarding failures silently, not the decoding itself. (That's deliberate: any decode-time transform on the codec, such as a payload-schema upcast, must run here too.)

⚠️ Gotchacheck isFailed() before value(). A failed row carries a null value and a non-null error(); a poisoned row straight into row.value().getName() is an NPE. On a failed row key() is best-effort: backends that store the key separately (SQL, Mongo, InMemory) still report the real key, while the file backends fall back to the file name (which equals the key when it needed no sanitization).

📌 Note — every backend implements true per-backend pagination, but the single-instance file backends may return the whole collection in one page, treating limit as advisory.

versions — cheap key → version read

CompletableFuture<Map<UUID, Long>> versions(Collection<UUID> keys);

A content-free read: key and version only, never the entity body (the file backends merely probe file/sub-document existence). It's the substrate the manager's polling cache sync uses to invalidate caches on backends without a native change feed — see Cross-Process Cache Sync.

⚠️ Gotcha — the returned number is only meaningful where the backend enforces versioning (MySQL/MariaDB, PostgreSQL, MongoDB). The file backends report a stamp derived from the file itself, which gives them update detection without a lock column. H2 reports 0 for every existing key — as does any non-versioned descriptor — so only presence/absence is reliable there: a poller can detect a deletion (a cached key missing from the result) but never an in-place update. In-memory reports whatever version is stored on the entity. Which backends enforce it is on Optimistic Locking.


Writing: save and saveAll

save — upsert one

CompletableFuture<Void> save(TestPlayer entity);

save is an upsert: insert if the key is new, replace if it exists. The key is taken from the entity by the descriptor's keyExtractor, then persisted by its toString().

repo.save(new TestPlayer(id, "Alice", 100)).join();   // insert
repo.save(new TestPlayer(id, "Alice", 250)).join();   // replace — same key, new score

⚠️ Gotcha — keys persist by toString() and must be ≤ 255 characters. An oversized key isn't truncated; the returned future completes exceptionally with IllegalArgumentException, so the key can never silently collide. The full key contract (stable, unique toString(), value equals/hashCode) lives on Entities, Keys & Collections.

If the descriptor opts into optimistic locking, a stale save fails with OptimisticLockException instead of overwriting — see Optimistic Locking.

saveAll — batched upsert

CompletableFuture<Void> saveAll(Collection<TestPlayer> entities);

Same upsert semantics, but backends optimize the batch instead of N round-trips:

  • SQL (MySQL/MariaDB/PostgreSQL/H2) — a single JDBC batch with a cached upsert-SQL string.
  • MongoDB — a bulkWrite of ReplaceOneModels, chunked at 1000 (non-versioned path).
  • LocalFile / GroupedFile / InMemory — entity-by-entity, but still through one call.
List<TestPlayer> roster = List.of(
        new TestPlayer(UUID.randomUUID(), "Alice", 100),
        new TestPlayer(UUID.randomUUID(), "Bob",   250),
        new TestPlayer(UUID.randomUUID(), "Carol", 50));

repo.saveAll(roster).join();

💡 Tip — prefer saveAll(list) over a loop of save(...) whenever you're writing more than a couple of entities; on SQL and Mongo it collapses many round-trips into one. saveAll is not a transaction, though — for all-or-nothing semantics wrap it in inTransaction(...) on a transactional backend (see Transactions).

WriteMode — never resurrect a deleted row

CompletableFuture<Void> saveAll(Collection<TestPlayer> entities, WriteMode mode);

The default for every save/saveAll is WriteMode.UPSERT. The two-arg overload lets a maintenance pass opt out of inserting:

Mode Key present Key absent
UPSERT (the default) replace insert
UPDATE_ONLY update silent no-op — never inserts

UPDATE_ONLY exists for passes that must never resurrect a row someone deleted concurrently — a full-collection rewrite running while other work deletes rows. It's what the eager payload-schema sweep writes with (see Payload Schema Evolution).

repo.saveAll(rewritten, WriteMode.UPDATE_ONLY).join();   // deleted-meanwhile keys stay deleted

📌 NoteUPDATE_ONLY keeps the optimistic-lock guard on a versioned descriptor: a concurrent update still raises OptimisticLockException (report it as a conflict), while a concurrent delete is absorbed as a no-op. The two are different events and stay distinguishable. A null mode is treated as UPSERT and behaves exactly like saveAll(Collection).


Deleting: delete

CompletableFuture<Boolean> delete(UUID key);

Returns true if the entity existed, false if the key was already absent — a no-op delete is not an error.

boolean removed = repo.delete(id).join();
if (!removed) {
    // key wasn't there to begin with
}

There is no batch-delete in the Repository contract; delete keys individually (compose the futures if you have many):

CompletableFuture<Void> all = CompletableFuture.allOf(
        ids.stream().map(repo::delete).toArray(CompletableFuture[]::new));
all.join();

Composing without blocking

Because everything is a CompletableFuture, CRUD calls chain into pipelines that never block a thread until you ask:

// "Load Alice, bump her score, save her back" — fully async.
repo.find(aliceId)
    .thenApply(opt -> opt.orElseThrow(() -> new IllegalStateException("no Alice")))
    .thenApply(alice -> { alice.setScore(alice.getScore() + 10); return alice; })
    .thenCompose(repo::save)               // CompletableFuture<Void>
    .whenComplete((ok, err) -> {
        if (err != null) log.warn("update failed", err);
    });

📌 Note — when you do block with .join(), a failed future throws a CompletionException whose getCause() is the real error (IllegalArgumentException for a bad key, OptimisticLockException for a version conflict, etc.). The Async API covers the error model in full.


See also

Clone this wiki locally