A new ORM and why we need it.

·

10 min read

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.

  1. jimmer-core: Immutable data
  2. jimmer-sql: ORM based on jimmer-core

Their respective responsibilities are as follows

  1. 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.

  2. 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:

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.

  1. Direct access to unspecified properties causes an exception.
  2. 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

  1. Lonely object, for example

    TreeNode lonelyObject = TreeNodeDraft.$.produce(draft ->
        draft.setName("Lonely object")
    );
    
  2. Shallow object tree, for example

    TreeNode shallowTree = TreeNodeDraft.$.produce(draft ->
        draft
            .setName("Shallow Tree")
            .setParent(parent -> parent.setName("Father"))
            .addIntoChildNodes(child -> parent.setName("Son"))
    );
    
  3. 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

  1. Save lonely entity

    sqlClient.getEntities().save(
        TreeNode lonelyObject = TreeNodeDraft.$.produce(draft ->
            draft
                .setName("RootNode")
                .setParent((TreeNode)null)
        )
    );
    
  2. 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)
                 )
         )
     );
    
  3. 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

  1. 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();
    
  2. 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();
    
  3. 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();
      
  4. 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();
    
  5. 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.

  1. When the parameter parentName is not null, the table join current -> parent is required
  2. When the parameter grandParentName is not null, you need to join current -> 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();