Queries
The EclipseStore engine takes care of persisting your object graph. Queries do not run on the data stored on disk — they run on your data in the local JVM heap. There is no query language, no SQL dialect, no DSL, no annotations. Anything you can express in plain Java against your collections is a query.
That means you keep full control over how your data is shaped, indexed and traversed.
The fastest "query" is the one you do not write at all, because the data is already where you need it: a field on a parent object, an entry in a HashMap, an item in a sorted List.
For everything else, the standard library — primarily Streams — is usually all you need.
Example model
The examples on this page assume the following small model.
public class Shop
{
private List<Article> articles = new ArrayList<>();
private List<Customer> customers = new ArrayList<>();
private List<Order> orders = new ArrayList<>();
// getters ...
}
public class Article
{
private String name;
private String category;
private double price;
private boolean available;
}
public class Customer
{
private long id;
private String name;
private String city;
}
public class Order
{
private Customer customer;
private List<OrderLine> lines;
private LocalDate date;
}
public class OrderLine
{
private Article article;
private int quantity;
}
Streams over collections
The most common pattern is a stream() over the collection you already hold a reference to.
// All articles that are currently out of stock
List<Article> unavailable = shop.getArticles().stream()
.filter(a -> !a.isAvailable())
.toList();
// Find a specific article by name
Optional<Article> coffee = shop.getArticles().stream()
.filter(a -> "Coffee".equals(a.getName()))
.findFirst();
// Count available articles
long availableCount = shop.getArticles().stream()
.filter(Article::isAvailable)
.count();
The same works on Set, Map.values(), Map.entrySet() or any Iterable via StreamSupport.stream(…).
Compared to SQL and Cypher
If you come from SQL, JPQL, HQL or Cypher, you might expect a dedicated query syntax — and miss it for about a day. Once the data is an object graph in your own JVM, the language is Java. That alone gives you four things a query language cannot:
-
Compile-time type safety — rename
Article.priceand every "query" that touches it either updates or fails to compile. -
Real IDE support — autocomplete, jump-to-definition, find-usages, refactor, all the way through the query.
-
Step-through debugging — set a breakpoint inside the lambda; inspect the entity that just failed the predicate.
-
No mapping layer — the result is already domain objects in the right types. No
ResultSet, noObject[], no DTO mapper.
The side-by-sides below are not "Java is shorter than SQL" trophies (sometimes it’s longer). The point is what each line means: in the SQL/Cypher version it’s a string the database parses; in the Java version it’s code your compiler and IDE already understand.
Filter
SELECT * FROM article WHERE available = FALSE;
shop.getArticles().stream()
.filter(a -> !a.isAvailable())
.toList();
Aggregate per group
SELECT category, AVG(price)
FROM article
GROUP BY category;
shop.getArticles().stream()
.collect(Collectors.groupingBy(
Article::getCategory,
Collectors.averagingDouble(Article::getPrice)));
The SQL returns rows of (VARCHAR, DOUBLE) that you still have to map.
The Java returns a typed Map<String, Double>, ready to use.
Join
This is where the gap really opens up.
"All distinct articles ordered by customers from Berlin" needs four tables, three joins and a WHERE in SQL:
SELECT DISTINCT a.*
FROM customer c
JOIN "order" o ON o.customer_id = c.id
JOIN order_line l ON l.order_id = o.id
JOIN article a ON a.id = l.article_id
WHERE c.city = 'Berlin';
In Java the relationships are references, so the join is just dereferencing them in order:
shop.getCustomers().stream()
.filter(c -> "Berlin".equals(c.getCity()))
.flatMap(c -> c.getOrders().stream())
.flatMap(o -> o.getLines().stream())
.map(OrderLine::getArticle)
.distinct()
.toList();
No join keys, no aliases, no foreign-key direction to reason about. If the relationship is bidirectional in your model, you can start from the article side and the query mirrors that — without rewriting any join clauses.
Existence check
SELECT c.*
FROM customer c
WHERE EXISTS (
SELECT 1
FROM "order" o
JOIN order_line l ON l.order_id = o.id
JOIN article a ON a.id = l.article_id
WHERE o.customer_id = c.id
AND a.name = 'Coffee'
);
shop.getCustomers().stream()
.filter(c -> c.getOrders().stream()
.flatMap(o -> o.getLines().stream())
.anyMatch(l -> "Coffee".equals(l.getArticle().getName())))
.toList();
The SQL needs a correlated subquery; the Java just nests the predicates the same way the data is nested.
Graph traversal (Cypher)
Cypher is closer in spirit, because it pattern-matches on a graph — but the pattern still lives in a separate language whose runtime is not your JVM.
MATCH (c:Customer {city: 'Berlin'})-[:PLACED]->(:Order)
-[:CONTAINS]->(:OrderLine)-[:OF]->(a:Article)
RETURN DISTINCT a;
The Java version is the join example above — same shape, no pattern syntax, no driver, no separate result mapper.
For variable-length traversals (Cypher’s -[:KNOWS*1..3]→), a small recursive method walks the references with full control over depth, cycle detection and pruning:
Set<Person> withinDistance(Person start, int maxDepth)
{
Set<Person> visited = new HashSet<>();
visit(start, maxDepth, visited);
return visited;
}
void visit(Person p, int depthLeft, Set<Person> visited)
{
if(depthLeft < 0 || !visited.add(p))
{
return;
}
for(Person friend : p.getFriends())
{
visit(friend, depthLeft - 1, visited);
}
}
The Cypher pattern is more concise when it fits. When it doesn’t — branching depth, conditional pruning, custom scoring — pattern syntax tends to hit a wall. Plain Java has no such wall.
|
This is not a claim that SQL or Cypher are bad — they are excellent at what they do, particularly across a network boundary against a dataset and a process you do not own. The point is narrower: if your data is already a Java object graph in your own JVM, introducing a query language buys you very little and costs you the compile-time safety, IDE support and debugability you get for free in Java. |
Sorting and paging
sorted takes any Comparator; chain thenComparing for tie-breakers, and use skip + limit for paging.
List<Article> byPriceThenName = shop.getArticles().stream()
.sorted(Comparator.comparingDouble(Article::getPrice)
.thenComparing(Article::getName))
.toList();
int pageSize = 50;
int pageIndex = 2; // zero-based
List<Article> page = shop.getArticles().stream()
.sorted(Comparator.comparing(Article::getName))
.skip((long) pageIndex * pageSize)
.limit(pageSize)
.toList();
For descending order, use Comparator.reversed() or Comparator.comparing(…).reversed().
Mapping and aggregation
The primitive stream specializations (mapToInt, mapToLong, mapToDouble) and Collectors.summarizing* cover the typical SQL aggregates.
// Sum: total value of all available articles
double inStockValue = shop.getArticles().stream()
.filter(Article::isAvailable)
.mapToDouble(Article::getPrice)
.sum();
// Average price across all articles
OptionalDouble averagePrice = shop.getArticles().stream()
.mapToDouble(Article::getPrice)
.average();
// All aggregates in one pass
DoubleSummaryStatistics stats = shop.getArticles().stream()
.collect(Collectors.summarizingDouble(Article::getPrice));
double min = stats.getMin();
double max = stats.getMax();
double avg = stats.getAverage();
long count = stats.getCount();
double sum = stats.getSum();
Projections with records
When you only need a subset of fields, a record is a one-line typed shape that doubles as a DTO.
record ArticleSummary(String name, double price) {}
List<ArticleSummary> summaries = shop.getArticles().stream()
.filter(Article::isAvailable)
.map(a -> new ArticleSummary(a.getName(), a.getPrice()))
.toList();
That’s the SQL SELECT name, price FROM article WHERE available — except the result is a list of typed, immutable objects, not a ResultSet, and the same record can flow straight into a controller, a test or a cache without any mapping layer.
Records are also the cleanest way to build composite group-by keys.
record CategoryYear(String category, int year) {}
Map<CategoryYear, Long> ordersPerCategoryAndYear = shop.getOrders().stream()
.flatMap(o -> o.getLines().stream()
.map(l -> new CategoryYear(
l.getArticle().getCategory(),
o.getDate().getYear())))
.collect(Collectors.groupingBy(k -> k, Collectors.counting()));
Grouping and partitioning
Collectors.groupingBy covers SQL’s GROUP BY; partitioningBy is the boolean special case.
// Number of articles per category
Map<String, Long> countByCategory = shop.getArticles().stream()
.collect(Collectors.groupingBy(
Article::getCategory,
Collectors.counting()));
// Average price per category
Map<String, Double> avgPriceByCategory = shop.getArticles().stream()
.collect(Collectors.groupingBy(
Article::getCategory,
Collectors.averagingDouble(Article::getPrice)));
// Split into available / unavailable in one pass
Map<Boolean, List<Article>> byAvailability = shop.getArticles().stream()
.collect(Collectors.partitioningBy(Article::isAvailable));
Top-N per group
SQL needs a window function for "the three most expensive articles per category":
SELECT *
FROM (
SELECT a.*,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM article a
)
WHERE rn <= 3;
In Java, a groupingBy whose downstream collector trims each group is enough.
Map<String, List<Article>> topThreePerCategory = shop.getArticles().stream()
.collect(Collectors.groupingBy(
Article::getCategory,
Collectors.collectingAndThen(
Collectors.toList(),
list -> list.stream()
.sorted(Comparator.comparingDouble(Article::getPrice).reversed())
.limit(3)
.toList())));
The result is a typed Map<String, List<Article>> ready to render — no row-number column, no subquery wrapper, no result-set mapping.
Multiple criteria
Compose Predicate`s with `and, or and negate instead of building one giant lambda.
Reusable predicates make the call site read like a sentence and keep test coverage simple.
Predicate<Article> available = Article::isAvailable;
Predicate<Article> cheap = a -> a.getPrice() < 10.0;
Predicate<Article> drink = a -> "Drinks".equals(a.getCategory());
List<Article> cheapAvailableDrinks = shop.getArticles().stream()
.filter(available.and(cheap).and(drink))
.toList();
List<Article> expensiveOrUnavailable = shop.getArticles().stream()
.filter(available.negate().or(cheap.negate()))
.toList();
Traversing relationships
What SQL solves with a JOIN, plain Java solves by following references.
flatMap flattens nested collections; anyMatch / allMatch / noneMatch express "exists" / "for all" without materialising results.
// All articles that have ever been ordered (with duplicates)
List<Article> everOrdered = shop.getOrders().stream()
.flatMap(o -> o.getLines().stream())
.map(OrderLine::getArticle)
.toList();
// Distinct customers who ever bought "Coffee"
Set<Customer> coffeeBuyers = shop.getOrders().stream()
.filter(o -> o.getLines().stream()
.anyMatch(l -> "Coffee".equals(l.getArticle().getName())))
.map(Order::getCustomer)
.collect(Collectors.toSet());
// Revenue per customer
Map<Customer, Double> revenuePerCustomer = shop.getOrders().stream()
.collect(Collectors.groupingBy(
Order::getCustomer,
Collectors.summingDouble(o -> o.getLines().stream()
.mapToDouble(l -> l.getArticle().getPrice() * l.getQuantity())
.sum())));
// Customers who placed an order in 2026
LocalDate from = LocalDate.of(2026, 1, 1);
LocalDate to = LocalDate.of(2027, 1, 1);
Set<Customer> activeIn2026 = shop.getOrders().stream()
.filter(o -> !o.getDate().isBefore(from) && o.getDate().isBefore(to))
.map(Order::getCustomer)
.collect(Collectors.toSet());
Encapsulate queries as domain methods
Most "queries" are about a specific entity — a customer’s revenue, an article’s stock level. Put the query on the class instead of repeating it in every caller.
public class Customer
{
private List<Order> orders = new ArrayList<>();
public double totalRevenue()
{
return this.orders.stream()
.flatMap(o -> o.getLines().stream())
.mapToDouble(l -> l.getArticle().getPrice() * l.getQuantity())
.sum();
}
public boolean hasEverBought(String articleName)
{
return this.orders.stream()
.flatMap(o -> o.getLines().stream())
.anyMatch(l -> articleName.equals(l.getArticle().getName()));
}
}
The call site becomes the readable version of the question:
double revenue = customer.totalRevenue();
List<Customer> coffeeBuyers = shop.getCustomers().stream()
.filter(c -> c.hasEverBought("Coffee"))
.toList();
This is what SQL views and Cypher procedures try to be — except here the method is part of the same compiled type, refactor-safe, debuggable and indistinguishable from any other piece of your domain model.
Maps as indexes
Streams scan; maps look up.
When a query is on the hot path and you know the access pattern, store the right map at the root and the query collapses to a single get.
public class Shop
{
// ...
private final Map<Long, Customer> customersById = new HashMap<>();
private final Map<String, List<Article>> articlesByCategory = new HashMap<>();
}
Customer c = shop.customersById.get(42L);
List<Article> drinks = shop.articlesByCategory
.getOrDefault("Drinks", List.of());
You populate and maintain these maps yourself in the methods that add or change entities, and you store the map alongside the entity that changed. This is the same trade-off any database makes between a sequential scan and a secondary index — only here the index is just a regular Java field.
For collections that are too large to keep fully resident, see Lazy Collections (LazyHashMap, LazyArrayList, LazyHashSet).
|
Range queries with NavigableMap
For range-based lookups — date windows, price bands, score thresholds — TreeMap (or any NavigableMap) covers the SQL BETWEEN / >= / ⇐ cases without any scan.
NavigableMap<LocalDate, List<Order>> ordersByDate = new TreeMap<>();
// populate / maintain this alongside writes ...
// All orders in March 2026
// SQL: WHERE date >= '2026-03-01' AND date < '2026-04-01'
NavigableMap<LocalDate, List<Order>> march = ordersByDate.subMap(
LocalDate.of(2026, 3, 1), true,
LocalDate.of(2026, 4, 1), false);
// Most recent order at or before today
// SQL: SELECT ... WHERE date <= ? ORDER BY date DESC LIMIT 1
Map.Entry<LocalDate, List<Order>> latest = ordersByDate.floorEntry(LocalDate.now());
subMap, headMap and tailMap return live views that walk only the matching range, not copies.
For multidimensional ranges (e.g. price and date), nest two `NavigableMap`s, or fall back to a GigaMap with the appropriate indices.
Materialized aggregates
When an aggregate is queried often and only changes on specific writes, store it as a field and update it where the write happens.
public class Shop
{
private final List<Order> orders = new ArrayList<>();
private long orderCount = 0;
private double revenueTotal = 0.0;
private final Map<Customer, Double> revenuePerCustomer = new HashMap<>();
public void addOrder(Order order)
{
this.orders.add(order);
double value = order.totalValue();
this.orderCount++;
this.revenueTotal += value;
this.revenuePerCustomer.merge(order.getCustomer(), value, Double::sum);
// store the changed entities in one transaction ...
}
}
Now shop.orderCount, shop.revenueTotal and shop.revenuePerCustomer.get(c) are all O(1) reads — no stream, no scan.
This is the same idea as a SQL materialized view, except the "refresh" runs synchronously inside the same method that performs the write, so the aggregate is always exactly consistent with the data.
|
Keep the derived state and the underlying entities under the same lock, and store them in the same transaction. A crash mid-method must not be able to leave the aggregate out of sync with its source — see Convenience Methods and Explicit Storing. |
Lazy references inside queries
If part of the graph sits behind an Lazy<T> reference, you have to ask for it before you can stream it.
Use the static Lazy.get(…) to stay null-safe.
public class BusinessYear
{
private Lazy<List<Order>> orders;
// ...
}
// Triggers load on first access; transparent on subsequent calls
List<Order> orders = Lazy.get(year.getOrders());
double yearRevenue = orders.stream()
.flatMap(o -> o.getLines().stream())
.mapToDouble(l -> l.getArticle().getPrice() * l.getQuantity())
.sum();
|
Streaming over a |
Parallel streams
Parallel streams are useful when the predicate or mapping does real CPU work over data that is already loaded.
double total = shop.getArticles().parallelStream()
.filter(Article::isAvailable)
.mapToDouble(Article::getPrice)
.sum();
|
Do not parallelise across |
Concurrency and consistency
Queries see the live object graph, including changes made by other threads. If your application updates entities concurrently with read-only queries, you have to coordinate that yourself — EclipseStore does not introduce hidden snapshots or transactions for queries.
Two pragmatic patterns:
-
Synchronise reads and writes through a single lock or a
ReadWriteLockaround the affected sub-graph. See Locking for ready-made utilities. -
Take a defensive copy at the start of a long-running query (
new ArrayList<>(shop.getArticles())) so concurrent mutations cannot affect the iteration.
For most read-heavy workloads, the second pattern is enough and avoids any contention with writers.
When you outgrow plain Java: GigaMap
Plain-Java queries assume the data you query against is loaded.
Once a single collection grows beyond what is sensible to keep on the heap — millions of entities, frequent equality / range / full-text lookups, or a need to query before loading — switch that collection to a GigaMap.
A GigaMap keeps an off-heap bitmap index, executes the query against the index, and only loads the segments that match.
The query API is fluent and composable, but conceptually still operates on Java objects.
GigaMap<Person> people = ...;
List<Person> berliners = people.query()
.and(PersonIndices.city.is("Berlin"))
.and(PersonIndices.age.greaterThanEqual(18))
.toList();
For the full surface — defining indices, condition methods, executing queries, multithreaded execution — see GigaMap Queries.