A new ORM and why we need it.
I've created a framework about immutable data and ORM for immutable data.
Project name: Jimmer
Project Home: babyfish-ct.github.io/jimmer-doc
Overview
Jimmer is divided into two parts, jimmer-core and jimmer-sql.
- jimmer-core: Immutable data
- jimmer-sql: ORM based on jimmer-core
Their respective responsibilities are as follows
jimmer-core
A new immutable object system is defined to define entity types as the cornerstone of ORM. The power of jimmer-sql is partly based on the power of jimmer-core.
As a plagiarism response to the kotlin data class, java14 added the record type for creating immutable objects. Even if you look at some non-jvm languages, you can see that more and more modern programming languages (such as C# 9.0) have built-in support for immutable objects, and immutable objects represent the development direction of future programming languages.
Because immutable objects can semantically confuse reference-based sharing and value-based copying, there is no need to worry about data internal details being accidentally modified by others, especially deep-level details and projects developed by multiple people. Only databases and caches have enough weight to be shared data that can be modified by multiple parties, allowing developers to think about or exploit the side effects of multiple modifications. Ordinary Java objects should completely eliminate this concern, which is why modern programming languages are increasingly favoring immutable objects.
Unfortunately, immutable objects have their problems, since an object is immutable, we cannot modify it directly, but need to create a new object based on the old one. If the object is just a simple object, the complexity that developers face is mediocre, but as long as the object has a certain depth of association, "modifying" deeply related objects can become a nightmare. In order to save space, this article does not discuss how painful this pain point is, please refer to this link.
However, java record (or kotlin data class) does not answer how to solve this pain point. For this reason, jimmer ported a feature called immer in the JS/TS field to Java. Because this is the strongest known solution for immutable objects until now, and is the root reason why this project is called jimmer.
Jimmer can create mutable temporary proxy based on existing immutable object. Since proxy is mutable, you can of course modify it, especially its deep associated objects. After the whole process is complete, the temporary proxy will use all the user modification behaviors it has collected to create another immutable object. This process seems to be as simple as directly modifying a traditional mutable object. Behind the scenes is a copy-on-write strategy based on the object tree. Only the changed part will be copied, and the unchanged part of the subtree will always be shared between the old and new object.
To work with ORMs, immutable objects are dynamic. Not all properties of an object need to be initialized, it allows some properties to be missing, a feature known as lazy loading in traditional ORMs, and not all object properties, especially associated properties, need to be queried from the database.
The unspecified property here is not null, but unknown. It will cause an exception when accessed directly by code, and will be automatically ignored in JSON serialization without exception.
In the traditional ORM, such objects with incomplete information can only be generated inside the ORM and returned to the user (for example, the lazy many-to-one association in Hibernate returns a proxy object, and the proxy only has an id); while in jimmer, Whether it is ORM or user code, both parties are free to construct incomplete dynamic object trees of any shape for each other to use. This is the fundamental reason why jimmer can provide far more functionality than other ORMs.
It is worth mentioning that there is another wonderful use of object dynamism. When defining entity types, bidirectional associations between types are allowed without design restrictions. However, when objects need to be created for specific business implementation, only one-way associations are allowed between objects to ensure that there is no circular reference, so as to facilitate the communication between microservices and the front-end. The purpose of
type definition allows bidirectional association + only single association between object instances
is to allow developers to realize the lag of business aggregate root design.jimmer-sql
ORM based on jimmer-core dynamic immutable objects.
In terms of implementation, jimmer-sql is incredibly lightweight, with no dependencies other than JDBC, not even some lightweight encapsulation for database connection like
SqlSession
of myBatis.Similar to QueryDsl, JOOQ, JPA Criteria, with strongly typed SQL DSLs, most SQL errors are reported at compile time rather than as runtime exceptions.
However, strongly-typed SQL DSL does not conflict with Native SQL. Through elegant API, Native SQL is mixed into strongly-typed SQL DSL, and developers are encouraged to use features specific to specific database products, such as analytical functions and regularization.
In addition to all ORM's must-have features, jimmer-sql provides 4 other features that far exceed other ORMs:
- Save command
- Object fetcher
- Dynamic table joins
-
These four powerful functions that are clearly different from other ORMs are the goals pursued by the ORM part of this framework.
1. Make User Bean powerful enough
1.1 Use immutable data, but support temporary mutable proxies.
@Immutable
public interface TreeNode {
String name();
TreeNode parent();
List<TreeNode> childNodes();
}
The annotation processor will generate a mutable derived interface for the user: TreeNodeDraft
. User can use it like this
// Step1: Create object from scratch
TreeNode oldTreeNode = TreeNodeDraft.$.produce(root ->
root
.setName("Root")
.addIntoChildNodes(child ->
child.setName("Drinks")
)
.addIntoChildNodes(child ->
child.setName("Breads")
)
);
// Step2: Create object based on existing object
TreeNode newTreeNode = TreeNodeDraft.$.produce(
oldTreeNode, // existing object
root ->
root.childNodes(false).get(0) // Get child proxy
.setName("Dranks+"); // Change child proxy
);
System.out.println("Old tree node: ");
System.out.println(oldTreeNode);
System.out.println("New tree node: ");
System.out.println(newTreeNode);
The final print result is as follows
Old tree node:
{"name": "Root", childNodes: [{"name": "Drinks"}, {"name": "Breads"}]}
New tree node:
{"name": "Root", childNodes: [{"name": "`Drinks+`"}, {"name": "Breads"}]}
1.2 Dynamic object.
Any property of the data object can be unspecified.
- Direct access to unspecified properties causes an exception.
- Using Jackson serialization, Unspecified properties will be ignored, without exception throwing.
TreeNode current = TreeNodeDraft.$.produce(current ->
node
.setName("Current")
.setParent(parent -> parent.setName("Father"))
.addIntoChildNodes(child -> child.setName("Son"))
);
// You can access specified properties
System.out.println(current.name());
System.out.println(current.parent());
System.out.println(current.childNodes());
/*
* But you cannot access unspecified fields, like this
*
* System.out.println(current.parent().parent());
* System.out.println(
* current.childNodes().get(0).childNodes()
* );
*
* , because direct access to unspecified
* properties causes an exception.
*/
/*
* Finally You will get JSON string like this
*
* {
* "name": "Current",
* parent: {"name": "Father"},
* childNodes:[
* {"name": "Son"}
* ]
* }
*
* , because unspecified will be ignored by
* jackson serialization, without exception throwing.
*/
String json = new ObjectMapper()
.registerModule(new ImmutableModule())
.writeValueAsString(current);
System.out.println(json);
Because entity objects are dynamic, users can build arbitrarily complex data structures. There are countless possibilities, such as
Lonely object, for example
TreeNode lonelyObject = TreeNodeDraft.$.produce(draft -> draft.setName("Lonely object") );
Shallow object tree, for example
TreeNode shallowTree = TreeNodeDraft.$.produce(draft -> draft .setName("Shallow Tree") .setParent(parent -> parent.setName("Father")) .addIntoChildNodes(child -> parent.setName("Son")) );
Deep object tree, for example
TreeNode deepTree = TreeNodeDraft.$.produce(draft -> draft .setName("Deep Tree") .setParent(parent -> parent .setName("Father") .setParent(deeperParent -> deeperParent.setName("Grandfather") ) ) .addIntoChildNodes(child -> child .setName("Son") .addIntoChildNodes(deeperChild -> deeperChild.setName("Grandson"); ) ) );
This object dynamism, which includes countless possibilities, is the fundamental reason why jimmer's ORM can provide more powerful features.
2. ORM base on immutable object.
In jimmer's ORM, entities are also immutable interfaces
@Entity
public interface TreeNode {
@Id
@GeneratedValue(
strategy = GenerationType.SEQUENCE,
generator = "sequence:TREE_NODE_ID_SEQ"
)
long id();
@Key // jimmer annotation, `name()` is business key,
// business key will be used when `id` property is not specified
String name();
@Key // Business key too
@ManyToOne
@OnDelete(DeleteAction.DELETE)
TreeNode parent();
@OneToMany(mappedBy = "parent")
List<TreeNode> childNodes();
}
Note!
Although jimmer uses some JPA annotations to complete the mapping between entities and tables, jimmer is not JPA.
2.1 Save arbitrarily complex object tree into database
Save lonely entity
sqlClient.getEntities().save( TreeNode lonelyObject = TreeNodeDraft.$.produce(draft -> draft .setName("RootNode") .setParent((TreeNode)null) ) );
Save shallow entity tree
sqlClient.getEntities().save( TreeNode lonelyObject = TreeNodeDraft.$.produce(draft -> draft .setName("RootNode") .setParent(parent -> parent.setId(100L) ) .addIntoChildNodes(child -> child.setId(101L) ) .addIntoChildNodes(child -> child.setId(102L) ) ) );
Save deep entity tree
sqlClient.getEntities().saveCommand( TreeNode lonelyObject = TreeNodeDraft.$.produce(draft -> draft .setName("RootNode") .setParent(parent -> parent .setName("Parent") .setParent(grandParent -> grandParent.setName("Grand parent") ) ) .addIntoChildNodes(child -> child .setName("Child-1") .addIntoChildNodes(grandChild -> grandChild.setName("Child-1-1") ) .addIntoChildNodes(grandChild -> grandChild.setName("Child-1-2") ) ) .addIntoChildNodes(child -> child .setName("Child-2") .addIntoChildNodes(grandChild -> grandChild.setName("Child-2-1") ) .addIntoChildNodes(grandChild -> grandChild.setName("Child-2-2") ) ) ) ).configure(it -> // Auto insert associated objects // if they do not exists in database it.setAutoAttachingAll() ).execute();
2.2 Query arbitrarily complex object trees from a database
Select root nodes from database (
TreeNodeTable
is a java class generated by annotation processor)List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select(treeNode); }) .execute();
Select root nodes and their child nodes from database (
TreeNodeFetcher
is a java class generated by annotation processor)List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select( treeNode.fetch( TreeNodeFetcher.$ .allScalarFields() .childNodes( TreeNodeFetcher.$ .allScalarFields() ) ) ); }) .execute();
Query the root nodes, with two levels of child nodes
You have two ways to do it
Specify a deeper tree format
List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select( treeNode.fetch( TreeNodeFetcher.$ .allScalarFields() .childNodes( // level-1 child nodes TreeNodeFetcher.$ .allScalarFields() .childNodes( // level-2 child nodes TreeNodeFetcher.$ .allScalarFields() ) ) ) ); }) .execute();
You can also specify depth for self-associative property, this is better way
List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select( treeNode.fetch( TreeNodeFetcher.$ .allScalarFields() .childNodes( TreeNodeFetcher.$ .allScalarFields(), it -> it.depth(2) // Fetch 2 levels ) ) ); }) .execute();
Query all root nodes, recursively get all child nodes, no matter how deep
List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select( treeNode.fetch( TreeNodeFetcher.$ .allScalarFields() .childNodes( TreeNodeFetcher.$ .allScalarFields(), // Recursively fetch all, // no matter how deep it -> it.recursive() ) ) ); }) .execute();
Query all root nodes, it is up to the developer to control whether each node needs to recursively query child nodes
List<TreeNode> rootNodes = sqlClient .createQuery(TreeNodeTable.class, (q, treeNode) -> { q.where(treeNode.parent().isNull()) // filter roots return q.select( treeNode.fetch( TreeNodeFetcher.$ .allScalarFields() .childNodes( TreeNodeFetcher.$ .allScalarFields(), it -> it.recursive(args -> // - If the node name starts with `Tmp_`, // do not recursively query child nodes. // // - Otherwise, // recursively query child nodes. !args.getEntity().name().startsWith("Tmp_") ) ) ) ); }) .execute();
2.3 Dynamic table joins.
In order to develop powerful dynamic queries, it is not enough to support dynamic where predicates, but dynamic table joins are required.
@Repository
public class TreeNodeRepository {
private final SqlClient sqlClient;
public TreeNodeRepository(SqlClient sqlClient) {
this.sqlClient = sqlClient;
}
public List<TreeNode> findTreeNodes(
@Nullable String name,
@Nullable String parentName,
@Nullable String grandParentName
) {
return sqlClient
.createQuery(TreeNodeTable.class, (q, treeNode) -> {
if (name != null && !name.isEmpty()) {
q.where(treeNode.name().eq(name));
}
if (parentName != null && !parentName.isEmpty()) {
q.where(
treeNode
.parent() // Join: current -> parent
.name()
.eq(parentName)
);
}
if (grandParentName != null && !grandParentName.isEmpty()) {
q.where(
treeNode
.parent() // Join: current -> parent
.parent() // Join: parent -> grand parent
.name()
.eq(grandParentName)
);
}
return q.select(treeNode);
})
.execute();
}
}
This dynamic query supports three nullable parameters.
- When the parameter
parentName
is not null, the table joincurrent -> parent
is required - When the parameter
grandParentName
is not null, you need to joincurrent -> parent -> grandParent
When the parameters parentName
and grandParent
are both specified, the table join paths current -> parent
and current -> parent -> grandParent
are both added to the query conditions. Among them, current->parent
appears twice, jimmer will automatically merge the duplicate table joins.
This means
`current -> parent`
+
`current -> parent -> grandParent`
=
--+-current
|
\--+-parent
|
\----grandParent
In the process of merging different table join paths into a join tree, duplicate table joins are removed.
The final SQL is
select
tb_1_.ID, tb_1_.NAME, tb_1_.PARENT_ID
from TREE_NODE as tb_1_
/* Two java joins are merged to one sql join*/
inner join TREE_NODE as tb_2_
on tb_1_.PARENT_ID = tb_2_.ID
inner join TREE_NODE as tb_3_
on tb_2_.PARENT_ID = tb_3_.ID
where
tb_2_.NAME = ? /* parentName */
and
tb_3_.NAME = ? /* grandParentName */
2.4 Automatically generate count-query by data-query.
Pagination query requires two SQL statements, one for querying the total row count of data, and the other one for querying data in one page, let's call them count-query and data-query.
Developers only need to focus on data-count, and count-query can be generated automatically.
// Developer create data-query
ConfigurableTypedRootQuery<TreeNodeTable, TreeNode> dataQuery =
sqlClient
.createQuery(TreeNodeTable.class, (q, treeNode) -> {
q
.where(treeNode.parent().isNull())
.orderBy(treeNode.name());
return q.select(book);
});
// Framework generates count-query
TypedRootQuery<Long> countQuery = dataQuery
.reselect((oldQuery, book) ->
oldQuery.select(book.count())
)
.withoutSortingAndPaging();
// Execute count-query
int rowCount = countQuery.execute().get(0).intValue();
// Execute data-query
List<TreeNode> someRootNodes =
dataQuery
// limit(limit, offset), from 1/3 to 2/3
.limit(rowCount / 3, rowCount / 3)
.execute();