Skip to main content

Feature list

Purpose

  • Strongly typed, dynamic, immutable object model
  • Revolutionary ORM

Part I: Powerful immutable data model

We port the popular JavaScript project immer to Java/Kotlin. You can manipulate immutable objects naturally and intuitively the same way you manipulate mutable objects, you can have all the well-known advantages of immutable objects without any notorious overhead. This is the most powerful solution for immutable objects.

Quick view
// Step 1, create data from scratch
TreeNode treeNode = TreeNodeDraft.$.produce(root -> {
root.setName("Root").addIntoChildNodes(food -> {
food
.setName("Food")
.addIntoChildNodes(drink -> {
drink
.setName("Drink")
.addIntoChildNodes(cococola -> {
cococola.setName("Cococola");
})
.addIntoChildNodes(fanta -> {
fanta.setName("Fanta");
});
;
});
;
});
});

// Step 2, make some "changes" based on the
// existing data to create new data.
TreeNode newTreeNode = TreeNodeDraft.$.produce(
treeNode, // existing data
root -> {
root
.childNodes(false).get(0) // Food
.childNodes(false).get(0) // Drink
.childNodes(false).get(0) // Cococola
.setName("Cococola plus");
}
);

System.out.println("treeNode:" + treeNode);
System.out.println("newTreeNode:" + newTreeNode);
Output
treeNode: {
"name":"Root",
"childNodes":[
{
"name":"Food",
"childNodes":[
{
"name":"Drink",
"childNodes":[
{"name":"Cococola"},
{"name":"Fanta"}
]
}
]
}
]
}
newTreeNode: {
"name":"Root",
"childNodes":[
{
"name":"Food",
"childNodes":[
{
"name":"Drink",
"childNodes":[
{"name":"Cococola plus"},
{"name":"Fanta"}
]
}
]
}
]
}

Jimmer can be used to replace java records(or kotlin data classes) in any context where immutable data structures are preferred. We use very effective mechanisms to detect changes and eliminate unnecessary replication overhead. In general, any change of an object would create a new object reference, that is, the object is immutable in the sense of any specific reference. The unchanged parts would be shared among all versions of the data in memory to avoid naive copying and achieve the best performance.

Jimmer could help you:

  1. Detect unexpected mutation and throw appropriate errors;
  2. Throw away the tedious boilerplate code for manipulating the deep structure of immutable objects, avoid manual replication and save the overhead of redundant copy construction;
  3. Make changes to draft objects that record and trace the modifications, and create any necessary copies automatically with the original intact.

With Jimmer, you don't need to learn specialized APIs or data structures to benefit from immutability.

To support ORM, Jimmer improves the dynamic features of objects. Any property of an object is allowed to be missing.

  • Missing properties cause exceptions when accessed directly with code;
  • Missing properties are automatically ignored during Jackson serialization and would not cause exceptions.

Part II: Revolutionary ORM Based on the Immutable Data Model

Three aspects should be considered in ORM design:

  1. Query.
  2. Update.
  3. Cache.

For jimmer-sql, each aspect is aimed at object trees with arbitrary depth rather than simple objects.

This distinctive design brings convenience unmatched by other popular solutions.

1. Ultimate performance

Benchmark report is here, benchmark source code is here

2. Strong typed SQL DSL

Check for errors at compile time rather than runtime whenever possible with strong typed SQL DSL like JPA Criteria, QueryDSL, or JOOQ. Kotlin nullsafety would be introduced to SQL for kotlin API.

Quick view
BookTable book = BookTable.$;
AuthorTableEx author = AuthorTableEx.$;

List<Book> books = sqlClient
.createQuery(book)
.where(
book.id().in(sqlClient
.createSubQuery(author)
.where(author.firstName().eq("Alex"))
.select(author.books().id())
)
)
.select(book)
.execute();

3. Mix native SQL

Strongly typed SQL DSL and Native SQL can be mixed without extra restrictions, Using database-specific features is very easy;

Quick view
BookTable book = BookTable.$;

List<Tuple3<Book, Integer, Integer>> rows = sqlClient
.createQuery(book)
.select(
book,
Expression.numeric().sql(
Integer.class,
"rank() over(order by %e desc)",
it -> {
it.expression(book.price());
}
),
Expression.numeric().sql(
Integer.class,
"rank() over(partition by %e order by %e desc)",
it -> {
it.expression(book.store().id());
it.expression(book.price());
}
)
)
.execute();

4. External cache

Work with any external cache. By default, the framework is just a very lightweight and powerful SQL generator without caching. Still, users can attach any external cache

  1. No assumptions are made about the user's cache technology selection, developers can choose any cache technology. The framework only manages and coordinates the cache, and does not do cache implementation.

  2. Unlike other ORMs, jimmer supports not only object cache, but also associative cache. The two are combined behind the scenes and can be used with object fetcher and GraphQL.

    So, external cache is designed for object trees with arbitrary depth, rather than simple objects.

For high-performance complex data structure queries, the following two tasks will cause a large workload for developers.

  1. Query different data fragments from different caches, and then manually merge them into a whole and return it.

  2. Ensure consistency between cache and database.

In order to relieve the developer from these two heavy tasks, the framework's caching mechanism is designed to be powerful enough and transparent to the developer.

5. Object Fetcher

Extend the ability of SQL. If a column in the query is an object type, it can be specified as the query format of the object. This format accepts any association depth and breadth and even allows recursively query self-association attributes. It can be considered that SQL has been extended to a capability comparable to GraphQL.

Quick view
Simple Fetcher
BookTable book = BookTable.$;

List<Book> books = sqlClient
.createQuery(book)
.orderBy(book.name())
.select(
book.fetch(
BookFetcher.$
.allScalarFields()
.store(
BookStoreFetcher.$
.allScalarFields()
)
.authors(
AuthorFetcher.$
.allScalarFields()
)
)
)
.execute();
Fetcher With filter
BookTable book = BookTable.$;

List<Book> books = sqlClient
.createQuery(book)
.orderBy(book.name())
.select(
book.fetch(
BookFetcher.$
.allScalarFields()
.store(
BookStoreFetcher.$
.allScalarFields()
)
.authors(
AuthorFetcher.$
.allScalarFields(),
// This filter sorts the associated collection
it -> it.filter(args -> {
args.orderBy(args.getTable().firstName());
args.orderBy(args.getTable().lastName());
})
)
)
)
.execute();
Recursively query self-association attributes
TreeNodeTable treeNode = TreeNodeTable.$;

List<TreeNode> rootNodes = sqlClient
.createQuery(treeNode)
.where(treeNode.parent().isNull())
.orderBy(treeNode.name())
.select(
treeNode.fetch(
TreeNodeFetcher.$
.allScalarFields()
.childNodes(
TreeNodeFetcher.$.allScalarFields(),
it -> {
// Recursively query, no matter how deep
it.recursive(args ->
// but exclude some subtrees
!args.getEntity().name().equals("XX")
);
it.filter(args -> {
args.orderBy(args.getTable().name());
});
}
)
)
)
.execute();
tip

This feature can work with external cache, this very useful, especially when recursively querying self-associative properties.

6. Save Command:

The data to be saved is no longer a simple object, but an arbitrarily complex object tree. No matter how complex the tree is, the framework takes care of all the internal details and the developers can handle the whole operation with a single statement. This feature is the inverse of the Object Fetcher.

Quick view
Save lonely object
Book toBeSaved = BookDraft.$.produce(book -> {
book
.setName("Algorithms")
.setEdition(4)
.setPrice(new BigDecimal("53.99"));
});
Book saved = sqlClient
.getEntities()
.save(toBeSaved)
.getModifiedEntity();
Save shallow object tree

If the associated objects of the saved object only have id, then only the current object and its associations with other objects can be modified (this may cause the middle table to be modified), not the associated objects themselves.

Book toBeSaved = BookDraft.$.produce(book -> {
book
.setName("Algorithms")
.setEdition(4)
.setPrice(new BigDecimal("53.99"))
.applyStore(store -> {
store.setId(7883L);
})
.addIntoAuthors(author -> {
author.setId(28756L);
})
.addIntoAuthors(author -> {
author.setId(634L);
});
});
Book saved = sqlClient
.getEntities()
.save(toBeSaved)
.getModifiedEntity();
Save deep object tree

If the associated objects of the saved object contains non-id properties, then

  • Not only the current object and its associations to other objects can be modified (which may cause the intermediate table to be modified), but also the associated objects can be further modified.

  • The depth of the saved object tree can be unlimited. However, no matter how deep it is, the framework can handle all the details inside it.

Book toBeSaved = BookDraft.$.produce(book -> {
book
.setName("Algorithms")
.setEdition(4)
.setPrice(new BigDecimal("53.99"))
.applyStore(store -> {
store.setName("O'REILLY");
})
.addIntoAuthors(author -> {
author
.setFirstName("Robert")
.setLastName("Sedgewick")
.setGender(Gender.MALE);
})
.addIntoAuthors(author -> {
author
.setFirstName("Kevin")
.setLastName("Wayne")
.setGender(Gender.MALE);
});
});
Book saved = sqlClient
.getEntities()
.saveCommand(toBeSaved)
.configure(it -> {
// Automatically create non-existing associated objects
it.setAutoAttachingAll();
})
.execute()
.getModifiedEntity();

7. Global filters

Developers can define global filters to isolate SQL query conditions that are generally suitable for business. Automatically applied to all queries instead of manually adding these query conditions to each query.

Quick view

Typical scenarios for global filters: soft delete, multi-tenancy, data row-based data authorization.

Here is a brief description of multi-tenancy as an example.

Define global filters
If spring is used, the code is as follows:
@Component
public class TenantFilter implements Filter<TenantAwareProps> {

protected final TenantProvider tenantProvider;

public TenantFilter(TenantProvider tenantProvider) {
this.tenantProvider = tenantProvider;
}

@Override
public void filter(FilterArgs<TenantAwareProps> args) {
String tenant = tenantProvider.get();
if (tenant != null) {
args.where(args.getTable().tenant().eq(tenant));
}
}
}

Here, we suppose

  • TenantAware is an abstract type for all entities that need to be divided by tenant (the annotation that decorates this type in Jimmer is @MappedSupperClass not @Entity).
  • tenantProvder is a simple dependency object whose only function is to resolve the tenant name from the current user information.

Developers need to configure global filters for SqlClient to enable filters. this detail will not be discuessed here, just look at the effect directly.

I. Filter aggregate root objects

Assuming that the Book entity inherits TenantAware, the usage is as follows

List<Book> books = sqlClient.getEntities.findAll(Book.class);
Or
BookTable book = BookTable.$;
List<Book> books = sqlClient
.createQuery(book)
.select(book)
.execute();

The generated SQL is as follows

select 
tb_1_.ID,
tb_1_.TENANT,
tb_1_.NAME,
tb_1_.EDITION,
tb_1_.PRICE,
tb_1_.STORE_ID
from BOOK as tb_1_
where tb_1_.TENANT = ?

The simplest query is used here, without any query parameters, but the final generated SQL still filters tb_1_.TENANT.

II. Filter associated objects

Not only aggregate root objects can be filtered, but also associated objects can be filtered.

Assuming that the Book entity inherits TenantAware, the usage is as follows

List<Author> authors = sqlClient.getEntities.findAll(
AuthorFetcher.$
.allScalarFields()
.books(
BookFetcher.$
.allScalarFields()
)
);
Or
AuthorTable author = AuthorTable.$;
List<Author> authors = sqlClient
.createQuery(author)
.select(
author.fetch(
AuthorFetcher.$
.allScalarFields()
.books(
BookFetcher.$
.allScalarFields()
)
)
)
.execute();

This will result in the following two SQL statements being generated

  1. Querty aggragate root objects

    select 
    tb_1_.ID, tb_1_.FIRST_NAME, tb_1_.LAST_NAME, tb_1_.GENDER
    from AUTHOR as tb_1_
  2. Query associated objects

    select 
    tb_2_.AUTHOR_ID,
    tb_1_.ID,
    tb_1_.TENANT,
    tb_1_.NAME,
    tb_1_.EDITION,
    tb_1_.PRICE
    from BOOK as tb_1_
    inner join BOOK_AUTHOR_MAPPING as tb_2_
    on tb_1_.ID = tb_2_.BOOK_ID
    where
    tb_2_.AUTHOR_ID in (?, ?, ?, ?, ?)
    and
    tb_1_.TENANT = ?

It's not hard to see that an object fetcher is used here. Although the object fetcher does not impose any field-level filters on the associated property Author.books, the global filter for the Book class still takes effect. As a result, the final production SQL still filters tenant information.

info

The filtering of associated objects is demonstrated here through object fetcher. In fact, the GraphQL function provided by Jimmer can also use global filters to filter related objects. The effect of the two is the same, so I won't go into details.

8. Trigger

Users can monitor database changes through triggers. Triggers can notify not only changes to objects, but also associated changes.

Quick view

Registration processing logic

sqlClient.getTriggers().addEntityListener(Book.class, e -> {
System.out.println("The object `Book` is changed");
System.out.println("\told: " + e.getOldEntity());
System.out.println("\tnew: " + e.getNewEntity());
});
sqlClient.getTriggers().addAssociationListener(BookProps.STORE, e -> {
System.out.println("The many-to-one association `Book.store` is changed");
System.out.println("\tbook id: " + e.getSourceId());
System.out.println("\tdetached book store id: " + e.getDetachedTargetId());
System.out.println("\tattached book store id: " + e.getAttachedTargetId());
});
sqlClient.getTriggers().addAssociationListener(BookStoreProps.BOOKS, e -> {
System.out.println("The one-to-many association `BookStore.books` is changed");
System.out.println("\tbook store id: " + e.getSourceId());
System.out.println("\tdetached book id: " + e.getDetachedTargetId());
System.out.println("\tattached book id: " + e.getAttachedTargetId());
});

The user also has to do a little extra work for the trigger to take effect. this detail will not be discussed here, just look at the effect directly.

Modify the foreign key of Book whose id is 7 in the data.

update BOOK set STORE_ID = 2 where ID = 7;

Here, the foreign key is modified to 2. Assuming that the old value before modification is 1, the following output can be obtained

The object `Book` is changed
old: {"id":7,"name":"Programming TypeScript","edition":1,"price":47.50,"store":{"id":1}}
new: {"id":7,"name":"Programming TypeScript","edition":1,"price":47.50,"store":{"id":2}}
The many-to-one association `Book.store` is changed
book id: 7
detached book store id: 1
attached book store id: 2
The one-to-many association `BookStore.books` is changed
book store id: 1
detached book id: 7
attached book id: null
The one-to-many association `BookStore.books` is changed
book store id: 2
detached book id: null
attached book id: 7

9. Implicit & dynamic table joins

Jimmer's table joins are designed for very complex queries, it have the following features:

  1. Without explicitly creating table joins, the natural fluent property reference path represents complex multi-table joins, such as:

    where(table.books.authors.company.city.name eq "ChengDu")

  2. Table joins that are never used are automatically ignored and do not appear in the final SQL.

  3. For joined associated object, if only its id property is accessed, jimmer will further ignore more unnecessary join. See Phantom join and Half join.

  4. Conflicting table joins in different paths will be automatically merged. See Dynamic join.

Quick view

This example shows automatic merge conflicting table joins.

For simplicity, only the simplest two-table joins is demonstrated.

public List<Book> findBooks(
@Nullable String storeName,
@Nullable String storeWebsite
) {
BookTable book = BookTable.$;

return sqlClient
.createQuery(book)
.whereIf(
storeName != null,
() -> book.store().name().like(storeName)
)
.whereIf(
storeWebsite != null,
() -> book.store().website().like(storeWebsite)
)
.select(book)
.execute();
}

Taking the above code as an example, book.store() in the Java code and table.store in the kotlin code represent the inner join of the association Book::store.

Obviously, the above code implements a dynamic query, and different parameters will cause different SQL to be generated.

When the above two conditions are met, in the final generated SQL, the table join of Book::store will only appear once, not twice.

10. Extremely simple pagination

In the paging scenario, developers only need to focus on querying the actual data, and the query on the number of rows is automatically generated and optimized by the framework.

Quick view
BookTable book = BookTable.$;

// Developers only need to focus on data-query
ConfigurableRootQuery<Book> query = sqlClient
.createQuery(book)
... Omit some code logic, including: ...
... 1. Arbitrarily complex dynamic conditions ...
... 2. Arbitrarily complex dynamic sorting ...
... 3. Arbitrarily complex subqueries ...
.select(book);

// count-query can be generated and optimized.
int rowCount = query.count();

// Query data from 1/3 to 2/3
List<Book> books = query
.limit(/*limit*/ rowCount / 3, /*offset*/ rowCount / 3)
.execute();

11. Extreme lightweight.

No reflection, no dynamic bytecode generation, guarantee to be Graal friendly.

Part-III: Support for Spring GraphQL

Provide rapid development support for Spring GraphQL introduced in spring boot 2.7

Quick view
// Implement many-to-one association 'Book::store'
@org.springframework.graphql.data.method.annotation.BatchMapping
public Map<Book, BookStore> store(Collection<Book> books) {
return sqlClient
.getReferenceLoader(BookProps.STORE)
.batchLoad(books);
}

// Implement many-to-many association 'Book::authors'
@org.springframework.graphql.data.method.annotation.BatchMapping
public Map<Book, List<Author>> authors(List<Book> books) {
return sqlClient
.getListLoader(BookProps.AUTHORS)
.batchLoad(books);
}
tip

This feature can work with external cache