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
- Java
- Kotlin
// 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);
// Step 1, create data from scratch
val treeNode = new(TreeNode::class).by {
name = "Root"
childNodes().addBy {
name = "Food"
childNodes().addBy {
name = "Drinks"
childNodes().addBy {
name = "Cococola"
}
childNodes().addBy {
name = "Fanta"
}
}
}
}
// Step 2, make some "changes" based on the
// existing data to create new data.
val newTreeNode = new(TreeNode::class).by(
treeNode // existing data
) {
childNodes()[0] // Food
.childNodes()[0] // Drinks
.childNodes()[0] // Cococola
.name += " plus"
}
println("treeNode: $treeNode")
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:
- Detect unexpected mutation and throw appropriate errors;
- 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;
- 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:
- Query.
- Update.
- 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
- Java
- Kotlin
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();
val books = sqlClient
.createQuery(Book::class) {
where(
table.id valueIn subQuery(Author::class) {
where(table.firstName eq "Alex")
select(table.books.id)
}
)
select(table)
}
.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
- Java
- Kotlin
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();
val rows = sqlClient
.createQuery(Book::class) {
select(
table,
sql(
Int::class,
"rank() over(order by %e desc)"
) {
expression(table.price)
},
sql(
Int::class,
"rank() over(partition by %e order by %e desc)"
) {
expression(table.store.id)
expression(table.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
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.
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.
Query different data fragments from different caches, and then manually merge them into a whole and return it.
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
- Java
- Kotlin
BookTable book = BookTable.$;
List<Book> books = sqlClient
.createQuery(book)
.orderBy(book.name())
.select(
book.fetch(
BookFetcher.$
.allScalarFields()
.store(
BookStoreFetcher.$
.allScalarFields()
)
.authors(
AuthorFetcher.$
.allScalarFields()
)
)
)
.execute();
val books = sqlClient
.createQuery(Book::class) {
orderBy(table.name)
select(
table.fetchBy {
allScalarFields()
store {
allScalarFields()
}
authors {
allScalarFields()
}
}
)
}
.execute()
Fetcher With filter
- Java
- Kotlin
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();
val books = sqlClient
.createQuery(Book::class) {
orderBy(table.name)
select(
table.fetchBy {
allScalarFields()
store {
allScalarFields()
}
authors({
// This filter sorts the associated collection
filter {
orderBy(table.firstName)
orderBy(table.lastName)
}
}) {
allScalarFields()
}
}
)
}
.execute()
Recursively query self-association attributes
- Java
- Kotlin
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();
val rootNodes = sqlClient
.createQuery(TreeNode::class) {
where(table.parent.isNull())
orderBy(table.name)
select(
table.fetchBy {
allScalarFields()
childNodes({
// Recursively query, no matter how deep
recursive {
// but exclude some subtrees
entity.name != "XX"
}
filter {
orderBy(table.name)
}
}) {
allScalarFields()
}
}
)
}
.execute()
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
- Java
- Kotlin
Book toBeSaved = BookDraft.$.produce(book -> {
book
.setName("Algorithms")
.setEdition(4)
.setPrice(new BigDecimal("53.99"));
});
Book saved = sqlClient
.getEntities()
.save(toBeSaved)
.getModifiedEntity();
val toBeSaved = new(Book::class).by {
name = "Algorithms"
edition = 4
price = BigDecimal("53.99")
}
val saved = sqlClient
.entities
.save(toBeSaved)
.modifiedEntity
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.
- Java
- Kotlin
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();
val toBeSaved = new(Book::class).by {
name = "Algorithms"
edition = 4
price = BigDecimal("53.99")
store().apply {
id = 7833L
}
authors().addBy {
id = 28756L
}
authors().addBy {
id = 634L
}
}
val saved = sqlClient
.entities
.save(toBeSaved)
.modifiedEntity
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.
- Java
- Kotlin
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();
val toBeSaved = new(Book::class).by {
name = "Algorithms"
edition = 4
price = BigDecimal("53.99")
store().apply {
name = "O'REILLY"
}
authors().addBy {
firstName = "Robert"
lastName = "Sedgewick"
gender = Gender.MALE
}
authors().addBy {
firstName = "Kevin"
lastName = "Wayne"
gender = Gender.MALE
}
}
val saved = sqlClient
.entities
.save(toBeSaved) {
// Automatically create non-existing associated objects
setAutoAttachingAll()
}
.modifiedEntity
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
- Java
- Kotlin
@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));
}
}
}
@Component
class TenantFilter(
protected val tenantProvider: TenantProvider
) : KFilter<TenantAware> {
override fun filter(args: KFilterArgs<TenantAware>) {
tenantProvider.tenant?.let {
args.apply {
where(table.tenant.eq(it))
}
}
}
}
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
- Java
- Kotlin
List<Book> books = sqlClient.getEntities.findAll(Book.class);
val books = sqlClient.entities.findAll(Book::class);
- Java
- Kotlin
BookTable book = BookTable.$;
List<Book> books = sqlClient
.createQuery(book)
.select(book)
.execute();
val books = SqlClient
.createQuery(Book::class) {
select(table)
}
.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
- Java
- Kotlin
List<Author> authors = sqlClient.getEntities.findAll(
AuthorFetcher.$
.allScalarFields()
.books(
BookFetcher.$
.allScalarFields()
)
);
val books = sqlClient.entities.findAll(
newFetcher(Author::class).by {
allScalarFields()
books {
allScalarFields()
}
}
);
- Java
- Kotlin
AuthorTable author = AuthorTable.$;
List<Author> authors = sqlClient
.createQuery(author)
.select(
author.fetch(
AuthorFetcher.$
.allScalarFields()
.books(
BookFetcher.$
.allScalarFields()
)
)
)
.execute();
val authors = SqlClient
.createQuery(Author::class) {
select(
table.fetchBy {
allScalarFields()
books {
allScalarFields()
}
}
)
}
.execute()
This will result in the following two SQL statements being generated
Querty aggragate root objects
select
tb_1_.ID, tb_1_.FIRST_NAME, tb_1_.LAST_NAME, tb_1_.GENDER
from AUTHOR as tb_1_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.
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
- Java
- Kotlin
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());
});
sqlClient.triggers.addEntityListener(Book::class) {
println("The object `Book` is changed");
println("\told: ${it.oldEntity}");
println("\tnew: ${it.newEntity}");
}
sqlClient.triggers.addAssociationListener(Book::store) {
println("The many-to-one association `Book.store` is changed");
println("\tbook id: ${it.sourceId}");
println("\tdetached book store id: ${it.detachedTargetId}");
println("\tattached book store id: ${it.attachedTargetId}");
}
sqlClient.triggers.addAssociationListener(BookStore::books) {
println("The one-to-many association `BookStore.books` is changed");
println("\tbook store id: ${it.sourceId}");
println("\tdetached book id: ${it.detachedTargetId}");
println("\tattached book id: ${it.attachedTargetId}");
}
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:
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")
Table joins that are never used are automatically ignored and do not appear in the final SQL.
For joined associated object, if only its id property is accessed, jimmer will further ignore more unnecessary join. See Phantom join and Half join.
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.
- Java
- Kotlin
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();
}
fun findBooks(
storeName: String?,
storeWebsite: String?
): List<Book> =
sqlClient
.createQuery(Book::class) {
storeName?.let {
where(table.store.name like it)
}
storeWebsite?.let {
where(table.store.website like it)
}
select(table)
}
.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
- Java
- Kotlin
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();
// Developers only need to focus on data-query
val query = sqlClient
.createQuery(Book::class) {
... Omit some code logic, including: ...
... 1. Arbitrarily complex dynamic conditions ...
... 2. Arbitrarily complex dynamic sorting ...
... 3. Arbitrarily complex subqueries ...
select(table)
}
// count-query can be generated and optimized.
val rowCount = query.count()
// Query data from 1/3 to 2/3
val 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
- Java
- Kotlin
// 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);
}
// Implement many-to-one association: 'Book::store'
@org.springframework.graphql.data.method.annotation.BatchMapping
fun store(
// Must use `java.util.List` because Spring-GraphQL has a bug: #454
books: java.util.List<Book>
): Map<Book, BookStore> =
sqlClient
.getReferenceLoader(Book::store)
.batchLoad(books)
// Implement many-to-many association: 'Book::authors'
@org.springframework.graphql.data.method.annotation.BatchMapping
fun authors(
// Must use `java.util.List` because Spring-GraphQL has a bug: #454
books: java.util.List<Book>
): Map<Book, List<Author>> =
sqlClient
.getListLoader(Book::authors)
.batchLoad(books)
This feature can work with external cache