by Razvan Popian, Horațiu Dan
Abstract
asentinel-orm is a lightweight ORM tool built
on top of Spring JDBC, particularly JdbcTemplate
, thus
it possesses most of the features one would expect from a basic ORM
– SQL generation, lazy loading etc.
By leveraging the JdbcTemplate
, it means it allows
participating in Spring managed transactions and it can be easily
integrated in any project that already uses
JdbcTemplate
as a means to interact with the
database.
asentinel-orm has been successfully used in several Tangoe products since 2015 and has been continually improved as required by the business needs. In the summer of 2024, it has officially become an open-source project, which we consider will accelerate its evolution and increase the number of contributors.
In this article, a sample application is built in order to outline several ORM key features:
- simple configuration
- straight forward domain entity modelling via custom annotations
- easy writing and secure execution of plain SQL statements
- automatic SQL statements’ generation
- dynamic schema – entities are enriched with additional runtime attributes, persisted and read without code changes
Application
Setup
- Java 21
- Spring Boot 3.4.0
- asentinel-orm 1.70.0
- H2 database
Configuration
In order to interact with the asentinel-orm and
leverage its functionalities, an instance of
OrmOperations
is required.
As stated in the JavaDoc, thisis the central interface for performing ORM operations and it is neither intended, nor required to be specifically implemented in the client code.
The sample application includes the configuration code to create a bean of this type.
@Bean public OrmOperations orm(SqlBuilderFactory sqlBuilderFactory, JdbcFlavor jdbcFlavor, SqlQuery sqlQuery) { return new OrmTemplate(sqlBuilderFactory, new SimpleUpdater(jdbcFlavor, sqlQuery)); }
OrmOperations
has 2 super interfaces:
SqlBuilderFactory
– createsSqlBuilder
instances that can be further used to create SQL queries.SqlBuilder
is able to auto-generate parts of the query, for instance, the one that selects the columns. The where clause, the order by clause, other conditions and the actual columns can be added, by using methods from theSqlBuilder
class as well. In the next part of this section, aSqlBuilder
generated query example is shown.
Updater
– used for saving entities to their respective database tables. It can perform inserts or updates depending on whether the entity is newly created or already existing. A strategy interface calledNewEntityDetector
exists, which is used for determining whether an entity is a new one. By default, theSimpleNewEntityDetector
is used.
All queries generated by the ORM are executed using an
SqlQueryTemplate
instance, which further needs a Spring
JdbcOperations
/
JdbcTemplate
to work. Eventually, all queries reach the
good old JdbcTemplate
through which they are executed
while participating in Spring transactions, just as any
JdbcTemplate
direct execution.
Database specific SQL constructs and logic are provided via
implementations of the JdbcFlavor
interface, further
injected into most of the beans mentioned above. In this article,
as a H2 database is used, a H2JdbcFlavor
implementation is configured.
The complete configuration of the ORM as part of the sample
application is OrmConfig
.
Implementation
The experimental domain model exposed by the sample application is straightforward and consists of two entities – car manufacturers andcar models.Representing exactly what their names denote, the relationship between them is obvious, one car manufacturer may own multiple car models.
In addition to its name, the car manufacturer is enriched with attributes (columns) that are input by the application user, dynamically, at runtime. The exemplified use-case is straight-forward:
- the user is requested to provide the aimed names and types for the dynamic attributes
- a couple of car manufacturers are created, with concrete values for previously added dynamic attributes, then
- the entities are loaded back described by both the initial and the runtime defined attributes
The initial entities are mapped over the below database tables.
CREATE TABLE CarManufacturers ( ID INT auto_increment PRIMARY KEY, NAME VARCHAR(255) ); CREATE TABLE CarModels( ID INT auto_increment PRIMARY KEY, CarManufacturer int, NAME VARCHAR(255), TYPE VARCHAR(15), foreign key (CarManufacturer) references CarManufacturers(id) );
The corresponding domain classes are decorated with ORM specific annotations in order to configure the mappings to the above database tables.
@Table("CarManufacturers") public class CarManufacturer { @PkColumn("id") private int id; @Column("name") private String name; @Child(parentRelationType = RelationType.MANY_TO_ONE, fkName = CarModel.COL_CAR_MANUFACTURER, fetchType = FetchType.LAZY) private List<CarModel> models = Collections.emptyList(); ... }
@Table("CarModels") public class CarModel { public static final String COL_CAR_MANUFACTURER = "CarManufacturer"; @PkColumn("id") private int id; @Column("name") private String name; @Column("type") private CarType type; @Child(fkName = COL_CAR_MANUFACTURER, fetchType = FetchType.LAZY) private CarManufacturer carManufacturer; ... } public enum CarType { CAR, SUV, TRUCK }
A few considerations:
@Table
– maps (associates) the class to a database table@PkColumn
– maps theid
(unique identifier) to the table primary key@Column
– maps a class member to a table column@Child
– defines the relationship with another entity- the
@Child
annotated members are configured to be lazily loaded - the
type
table column is mapped to anenum
field –CarType
In order for the CarManufacturer
class to support
runtime defined attributes (mapped to runtime defined table
columns), a subclass as the one below is defined:
public class CustomFieldsCarManufacturer extends CarManufacturer implements DynamicColumnsEntity<DynamicColumn> { private final Map<DynamicColumn, Object> customFields = new HashMap<>(); ... @Override public void setValue(DynamicColumn column, Object value) { customFields.put(column, value); } @Override public Object getValue(DynamicColumn column) { return customFields.get(column); } ... }
This classstores the runtime defined attributes (fields) in a
Map
. The interaction between the runtime field values
and the ORM is fulfilled via the implementation of the
DynamicColumnEntity
interface.
public interface DynamicColumnsEntity<T extends DynamicColumn> { void setValue(T column, Object value); Object getValue(T column); }
setValue()
– is used to set the value of the runtime defined column when this is read from the tablegetValue()
– is used to retrieve the value of a runtime defined column when this is saved to the table
The DynamicColumn
maps runtime defined attributes to
their corresponding columns in a similar manner the
@Column
annotation maps compile time known members.
When running the application, the CfRunner
is
executed. The user is asked to input names and types for the
desired dynamic custom attributes that enrich the
CarManufacturer
entity (for simplicity, only
int
and varchar
types are supported).
For each such name – type pair, a DML command is executed in
order for the new columns to be added to the
CarManufacturer
database table. The following method
(declared in CarService
) performs the operation.
@Transactional public void addManufacturerField(String name, String type) { orm.getSqlQuery() .update("alter table CarManufacturers add column " + name + " " + type); }
Each input attribute is recorded as a
DefaultDynamicColumn
, a
DynamicColumn
reference implementation.
Once all attributes are defined, two car manufacturers are added in the database, as the user provides values for each such attribute.
Map<DynamicColumn, Object> dynamicColumnsValues = new HashMap<>(); for (DynamicColumn dynamicColumn : dynamicColumns) { // read values for each dynamic attribute ... } CustomFieldsCarManufacturer mazda = new CustomFieldsCarManufacturer("Mazda", dynamicColumnsValues); carService.createManufacturer(mazda, dynamicColumns);
The below method (declared in CarService
) actually
creates the entity via the ORM.
@Transactional public void createManufacturer(CustomFieldsCarManufacturer manufacturer, List<DynamicColumn> attributes) { orm.update(manufacturer, new UpdateSettings<>(attributes, null)); }
The 2 parameter version of the
OrmOperations
update()
method is called,
which allows passing an UpdateSettings
instance and
communicating to the ORM upon execution that there are runtime
defined whose values shall be persisted.
Lastly, two car models are created, corresponding to one of the previously added car manufacturers.
CarModel mx5 = new CarModel("MX5", CarType.CAR, mazda); CarModel cx60 = new CarModel("CX60", CarType.SUV, mazda); carService.createModels(mx5, cx60);
The below method (declared in CarService
) actually
creates the entities via the ORM, this time using
OrmOperations
update() method for persisting entities
without dynamic attributes. For convenience, multiple entities are
created in one call.
@Transactional public void createModels(CarModel... models) { orm.update(models); }
As a last step, one of the created manufacturers is loaded back, by its name, using an ORM generated query.
CarManufacturer mazda1 = carService.findManufacturerByName("Mazda", dynamicColumns); @Transactional(readOnly = true) public CarManufacturer findManufacturerByName(String name, List<DynamicColumn> attributes) { return orm.newSqlBuilder(CustomFieldsCarManufacturer.class) .select( AutoEagerLoader.forPath(CarManufacturer.class, CarModel.class), new DynamicColumnsEntityNodeCallback<>( new DefaultObjectFactory<>(CustomFieldsCarManufacturer.class), attributes ) ) .where().column("name").eq(name) .execForEntity(); }
A few explanations are worth doing regarding the method defined above.
The OrmOperations
newSqlBuilder()
method
creates a SqlBuilder
instance and as the name
suggests, this can be used to generate SQL queries. The
SqlBuilder
select()
method generates the
select from table part of the query, while the rest
(where, order by) must be added. The query select part can
be customized by passing
EntityDescriptorNodeCallback
instances (details on
EntityDescriptorNodeCallback
may be the subject of a
future article).
In order to let the ORM know that the plan is to select and map
runtime defined columns, a
DynamicColumnsEntityNodeCallback
needs to be passed.
Together with it, an AutoEagerLoader
one is provided,
so that the ORM understands to eagerly load the list of
CarModel
sassociated with the manufacturer.
Nevertheless, this has nothing to do with the runtime defined
attributes, but it demonstrates how a child member can be eagerly
loaded.
Conclusions
While there are probably other ways of working with runtime defined columns when data is stored in relational databases, the approach presented in this article has the advantage of using standard database columns that are read/written using standard SQL queries which are generated directly by the ORM.
It wasn’t rare when we had the chance to discuss in “the community” about the asentinel-orm, about the reasons we had to develop such a tool. Usually, at the first glance, developers proved to be reluctant and reserved when it came to a custom made ORM, asking why not using Hibernate or other JPA implementations.
In our case, the main driver was the need for a fast, flexible and easy way of working with sometimes quite a big number of runtime defined attributes (columns) for entities that are part of the business domain. For us, it proves to be the right way, the applications are running smoothly in production, the customers are happy with the speed and the achieved performance, the developers are comfortable and creative with the intuitive API.
As the project is open-source now, it is very easy for anyone interested to have a look, form an objective opinion about it and, why not, fork it, open a PR and contribute.
Resources
- The open-source ORM project is here.
- The source code of the sample application is here.
- The picture was taken in a hand-made decorations shop, in Skiathos, Greece, in the summer of 2024.