Skip to content

CRUD Operations

Petrus Pradella edited this page Jun 18, 2026 · 2 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 here 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

The interface lives at core/.../Repository.java. Get one Repository per descriptor from a live Storage; the storage caches it, 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. Idiomatic handling composes 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 a cheaper "is this key present?" than find(...).thenApply(Optional::isPresent) — backends answer it without materializing the entity. count() is the total number of stored entities in the collection.

all — stream every entity

CompletableFuture<Stream<TestPlayer>> all();

The future yields a Stream that backends materialize internally (paginating large datasets), so 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.


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 issuing 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 / 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).


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 them to:

// "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