Skip to content
Martin Ledvinka edited this page Jun 5, 2026 · 17 revisions

We wrote a paper comparing JOPA with other object-triple mapping libraries (such as AliBaba, Empire, KOMMA, RDFBeans). It presents:

  • A feature comparison of the libraries
  • A benchmark showcasing typical application operations - create, retrieve, retrieve all (instances of a specific type), update, delete

More information about the benchmark, the methodology, raw data and the original results can be found at https://kbss.felk.cvut.cz/web/otm-benchmark.

However, the benchmark is quite old. The following table thus provides a perspective on how the performance of JOPA in the benchmark has evolved since version 0.10.7 (the version used in the original benchmark). The benchmark was run with the following setup:

  • Linux Mint 21.3 (kernel 6.8.0-87-generic) - disabled network, no other applications running
  • AMD Ryzen 7 PRO 4750U
  • 32GB RAM
  • GraphDB 10.8.4
JOPA version Create   Retrieve   Retrieve all   Update   Delete  
  Mean/ms σ / ms Mean / ms σ / ms Mean / ms σ / ms Mean / ms σ / ms Mean / ms σ / ms
0.10.7 16694.17 181.4378 11930.94 871.2306 12077.37 921.4616 15142.52 455.5812 25580.1 959.1004
2.6.0 12338.22 637.955 12660.44 420.3243 12991.11 382.5462 15234.75 536.3642 9607.23 520.1247
2.7.0 11915.46 612.0381 12371.51 765.871 12521.72 650.8022 15111.65 522.3286 9819.639 518.1418
2.7.2* 11191.47 486.3428 12422.76 664.9563 12202.79 769.1189 14785.78 543.1824 9432.529 565.5442

*With enabled entity loading optimization (see below)

Entity Loading Query Optimization

By default, a query that returns entities is processed in two phases:

  1. The query is executed, returning only identifiers of the resulting entities
  2. Each resulting entity is loaded using the underlying access API (just as if EntityManager.find was called for the result element)

This makes query result loading typically a little slower and requires more requests to the repository (n+1 where n is the number of result rows). However, it gives JOPA better control over inferred/asserted data to be loaded, cache access and repository contexts.

Nevertheless, it is often more favorable to just load the entities directly from the query result (thus requiring only one request to the repository). Two optimizers are (as of 2.7.1) available for optimized entity loading from query results.

  • Unbound predicate and object optimizer adds, as its name suggests, a triple pattern of the form ?x ?xP ?xV into the query, making the query basically return triples with subject ?x. The entity is then loaded from them. However, this may retrieve more data than expected and could make the query run slow. Therefore, this pattern is used only if the target type has a @Properties field
  • Attribute enumerating optimizer adds optional groups (e.g., OPTIONAL { ?x ex:stringAttribute ?stringAttribute }) for each of the attributes of the target entity class. If the attribute is required (e.g., it has @ParticipationConstraints(nonEmpty=true)), the attribute triple pattern is added without the OPTIONAL (i.e., ?x ex:firstName ?firstName) This narrows the amount of data actually retrieved by the query and should improve the query performance. This is the default optimizer used when the target entity class does not have a @Properties field. This optimizer is also able to work with repository contexts specified by the descriptor passed to the query API.

Take the following typical query - select all instances of a type and see how it is modified by the optimizers:

SELECT ?x WHERE { ?x a ?type . }

# Unbound predicate and object optimizer
SELECT ?x ?xP ?xV WHERE { ?x a ?type . ?x ?xP ?xV . }

# Attribute enumerating optimizer (assume target entity class represents a person with firstName, familyName, accountName
SELECT ?x ?xfirstName ?xfamilyName ?xaccountName WHERE {
  ?x a ?type .
  OPTIONAL { ?x foaf:firstName ?xfirstName }
  OPTIONAL { ?x foaf:familyName ?xfamilyName }
  OPTIONAL { ?x foaf:accountName ?xaccountName }
}

Entity loading optimizers are not used when the query uses OFFSET or LIMIT (or the corresponding Query API methods setFirstResult or setMaxResults are used), because they both influence the number of query result rows and could thus skew the actual results.

It is also necessary to emphasize that using the loading optimizers means that all inferred data are included in the loaded entity (or the query has to be executed without inference at all), so the loaded entities may contain more data than if they were loaded using the default mechanism.

Entity loading query result optimization can be enabled for individual queries using the cz.cvut.kbss.jopa.query.enableEntityLoadingOptimizer query hint (use the QueryHints class for convenience).

From experience, this optimizer does not provide a significant performance boost, likely because of the OPTIONAL clauses in the query that make its execution more complex. So, try to make as many attributes required to reduce the number of OPTIONALs. However, it can significantly reduce the number of repository calls (if that is what you care about).

Fetch Graph-based Entity Loading

Since 2.10.0

A fetch graph may be passed to a query as a hint (cz.cvut.kbss.jopa.query.fetchGraph) describing which parts of an object graph related to the query's root should be fetched. This allows JOPA to optimize the fetching by modifying the query in such a way that all the relevant data are loaded as part of the query execution and no additional repository calls are necessary. This has the potential of turning a O(1+n*m) complexity operation (1 for query execution, n root entities matching the query, m eagerly fetched references of each root entity in the object graph) into a O(1) operation (just run the query).

An instance of cz.cvut.kbss.jopa.model.EntityGraph is used to describe the fetch graph and can be obtained from the EntityManager for an entity class. When the EntityGraph instance is passed as a fetch graph hint to the query, JOPA will, based on the EntityGraph, modify the query so that all the attributes and references specified by the EntityGraph are retrieved as part of the query and the result loader will then reconstruct the corresponding entities from them.

Consider a simple model represented by the following class diagram: image and assume that both hasPhone and hasManufacturer have fetch type EAGER in the corresponding entity classes.

A typical query to get all persons (SELECT p FROM Person p) would by default:

  1. Run the query to get identifiers of all matching instances (translated SPARQL would be something like SELECT ?x WHERE { ?x a ex:person . })
  2. Load each instance of Person based on the identifier
  3. Load each Phone associated with the person
  4. Load the manufacturer of each phone (presumably these will hit second-level cache quite often)

That can be a lot of calls to the repository. Let's assume we are interested only in basic metadata of each person and we do not need info about their phones (for example for a table with the list of all persons in the system, phone would be relevant only when the user would navigate to the person's detail page). We do not want to change the fetch type for Person.hasPhone. In that case, we could use a fetch graph as follows:

EntityManager em = // obtain entity manager
EntityGraph<Person> fetchGraph = em.createEntityGraph(Person.class);
fetchGraph.addAttributeNodes("firstName", "lastName", "username");

This will result in JOPA modifying the query as follows (assuming none of the attributes in Person have minimum cardinality of 1):

SELECT ?x ?x_firstName ?x_lastName ?x_username WHERE {
  ?x a ex:person .
  OPTIONAL { ?x ex:hasFirstName ?x_firstName . }
  OPTIONAL { ?x ex:hasLastName ?x_lastName . }
  OPTIONAL { ?x ex:hasUsername ?x_username . }
}

And JOPA will reconstruct the Person instances based on the returned data. If we created the fetch with reference to Phone and selected only its model:

// The graph can also be declared using the @NamedEntityGraph annotation
EntityManager em = // obtain entity manager
EntityGraph<Person> fetchGraph = em.createEntityGraph(Person.class);
fetchGraph.addAttributeNodes("firstName", "lastName", "username");
Subgraph<Phone> phoneGraph = fetchGraph.addSubgraph("hasPhone");
phoneGraph.addAttributeNodes("model");

Resulting in the following SPARQL query:

SELECT ?x ?x_firstName ?x_lastName ?x_username ?x_hasPhone ?x_hasPhone_model WHERE {
  ?x a ex:person .
  OPTIONAL { ?x ex:hasFirstName ?x_firstName . }
  OPTIONAL { ?x ex:hasLastName ?x_lastName . }
  OPTIONAL { ?x ex:hasUsername ?x_username . }
  OPTIONAL {
    ?x ex:hasPhone ?x_hasPhone .
    ?x_hasPhone a ex:phone .
    OPTIONAL { ?x_hasPhone ex:hasModel ?x_hasPhone_model . }
  }
}

Limitations of Using Fetch Graphs

There are some limitations to this approach of generating SPARQL queries to load entity data (which is why it is not used by default and only enabled by explicitly providing the fetch graph).

  • SPARQL does not allow distinguishing asserted and inferred data. Since JOPA does distinguish between them, using fetch graph bypasses this feature and may lead to more data loaded for an entity that when using em.find.
  • For plural attributes this could lead to a significant blowup of the number of result rows because the SPARQL engine will combine rows with different values for the same subject. JOPA is able to load to result correctly, but the query result set itself could be so large that the server may fail to return a proper response or its processing (generating and serialization on server, deserialization and processing on the client) may have worse performance than the default result loading mechanism. JOPA attempts to prevent this using SPARQL 1.1 GROUP_CONCAT, but this has its own limitations (will be discussed below).
  • Since the number of result rows may be higher than when executing just the basic query, this approach does not work when the query contains LIMIT or OFFSET clauses, because it could return incorrect results.
  • Named graphs (repository contexts) are supported in that when a Descriptor with contexts is provided for the query, the named graphs will be added to the modified query. At most one context must be specified for each entity in the object graph.
  • If the query itself contains SERVICE or GRAPH clauses, the query modification is disabled.
  • Do not use fetch graph in reference cycles.

Using GROUP_CONCAT to Reduce Number of Result Rows

As described above, the fetch graph-based query rewriting may lead to a significant increase in the number of result set rows. For instance, if a person in the previous example has five phones (and assuming they have firstName, lastName, and username), the object graph centered around that person instance will be represented by 5 rows. If Person had another plural attribute, the number of rows would be multiplied by the number of results for that attribute (this applies recursively).

To mitigate this issue, JOPA will attempt to group values of plural attributes using SPARQL 1.1 GROUP_CONCAT. However, this optimization has its own limitations:

  • It cannot be used on whole entities (so the Person-Phone example above cannot be optimized by it), only on direct values.
  • It can only be used on the 'leaf' entity in an entity reference chain. Let's say A points to multiple Bs which point to multiple Cs which have a set of references represented by URIs. In this case, only C.references can be optimized by GROUP_CONCAT to preserve correct pairing of result rows with values.

Currently, the GROUP_CONCAT optimization works for:

  • types (@Types)
  • Plural object property attributes whose values are only identifiers, not entities
  • Multilingual string attributes
  • Plural literal values

The following sample entity shows where GROUP_CONCAT can and cannot be used

@OWLClass(iri = "ex:Sample")
public class Sample {

  // CANNOT optimize with GROUP_CONCAT
  @OWLObjectProperty(iri = "ex:pluralReferenceEntity")
  private Set<Thing> pluralReferenceEntity;

  // CAN optimize with GROUP_CONCAT, would work also for String and URL
  @OWLObjectProperty(iri = "ex:pluralReferenceUri")
  private Set<URI> pluralReferenceUri;

  // CAN optimize with GROUP_CONCAT
  @OWLAnnotationProperty(iri = "RDFS.LABEL")
  private MultilingualString label;

  // CAN optimize with GROUP_CONCAT
  @OWLDataProperty(iri = "ex:evaluations")
  private Set<Integer> evaluations;

  // CAN optimize with GROUP_CONCAT
  @Types
  private Set<String> types;
}

Note that the GROUP_CONCAT optimization is disabled when query contains an ORDER BY clause as it could break the ordering (and it actually applies an ordering by itself).

Fetch graph usage example can be found in JOPA Examples.

Clone this wiki locally