Asentinel-orm est un outil ORM léger construit sur Spring JDBC, en particulier JdbcTemplate
. Ainsi, il possède la plupart des fonctionnalités que l’on attend d’un ORM de base, telles que la génération de SQL, le chargement paresseux, etc.
En exploitant le JdbcTemplate
, cela signifie qu’il permet de participer aux transactions gérées par Spring et peut être facilement intégré dans tout projet utilisant déjà JdbcTemplate
pour interagir avec la base de données.
Depuis 2015, asentinel-orm a été utilisé avec succès dans plusieurs applications et continuellement amélioré selon les besoins de l’entreprise. À l’été 2024, il est officiellement devenu un projet open-source, ce qui, selon nous, accélérera son évolution et augmentera le nombre de contributeurs.
Dans cet article, une application d’exemple est construite pour mettre en évidence plusieurs fonctionnalités clés de l’ORM :
- Configuration simple
- Modélisation simple des entités de domaine via des annotations personnalisées
- Écriture facile et exécution sécurisée de requêtes SQL brutes
- Génération automatique de requêtes SQL
- Schéma dynamique (les entités sont enrichies d’attributs supplémentaires à l’exécution, persistées et lues sans modifications de code)
Application
Configuration
- Java 21
- Spring Boot 3.4.0
- asentinel-orm 1.70.0
- Base de données H2
Configuration
Pour interagir avec asentinel-orm et tirer parti de ses fonctionnalités, une instance de OrmOperations
est requise.
Comme indiqué dans la JavaDoc, ce est l’interface centrale pour effectuer des opérations ORM, et il n’est ni prévu ni nécessaire d’être spécifiquement implémenté dans le code client.
L’application d’exemple inclut le code de configuration pour créer un bean de ce type.
public OrmOperations orm(SqlBuilderFactory sqlBuilderFactory,
JdbcFlavor jdbcFlavor, SqlQuery sqlQuery) {
return new OrmTemplate(sqlBuilderFactory, new SimpleUpdater(jdbcFlavor, sqlQuery));
}
OrmOperations
a deux super interfaces :
- SqlBuilderFactory – crée des instances de
SqlBuilder
qui peuvent ensuite être utilisées pour créer des requêtes SQL.SqlBuilder
est capable de générer automatiquement des parties de la requête, par exemple, celle qui sélectionne les colonnes. La clause where, la clause order by, d’autres conditions et les colonnes réelles peuvent également être ajoutées à l’aide des méthodes de la classeSqlBuilder
. Dans la prochaine partie de cette section, un exemple de requête générée par unSqlBuilder
est présenté. - Updater – utilisé pour sauvegarder des entités dans leurs tables de base de données respectives. Il peut effectuer des insertions ou des mises à jour selon que l’entité est nouvellement créée ou déjà existante. Une interface de stratégie appelée
NewEntityDetector
existe, qui est utilisée pour déterminer si une entité est nouvelle. Par défaut, leSimpleNewEntityDetector
est utilisé.
Toutes les requêtes générées par l’ORM sont exécutées à l’aide d’une instance de SqlQueryTemplate
, qui nécessite également un JdbcOperations
/JdbcTemplate
de Spring pour fonctionner. En fin de compte, toutes les requêtes atteignent le bon vieux JdbcTemplate
par lequel elles sont exécutées tout en participant aux transactions Spring, tout comme toute exécution directe de JdbcTemplate
..
Des constructions SQL spécifiques à la base de données et une logique sont fournies via des implémentations de l’interface JdbcFlavor
, qui sont ensuite injectées dans la plupart des beans mentionnés ci-dessus. Dans cet article, comme une base de données H2 est utilisée, une implémentation de H2JdbcFlavor
est configurée.
La configuration complète de l’ORM dans le cadre de l’application exemple est OrmConfig
.
Implémentation
Le modèle de domaine expérimental exposé par l’application exemple est simple et se compose de deux entités : les fabricants de voitures et les modèles de voitures. Représentant exactement ce que leurs noms désignent, la relation entre eux est évidente : un fabricant de voitures peut posséder plusieurs modèles de voitures.
En plus de son nom, le fabricant de voitures est enrichi d’attributs (colonnes) qui sont saisies par l’utilisateur de l’application dynamiquement à l’exécution. Le cas d’utilisation exemplifié est simple :
- L’utilisateur est invité à fournir les noms et types visés pour les attributs dynamiques
- Quelques fabricants de voitures sont créés avec des valeurs concrètes pour les attributs dynamiques précédemment ajoutés, puis
- Les entités sont rechargées, décrites à la fois par les attributs initiaux et ceux définis à l’exécution
Les entités initiales sont mappées en utilisant les tables de base de données ci-dessous :
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)
);
Les classes de domaine correspondantes sont décorées avec des annotations spécifiques à l’ORM pour configurer les mappages vers les tables de base de données ci-dessus.
"CarManufacturers") (
public class CarManufacturer {
"id") (
private int id;
"name") (
private String name;
parentRelationType = RelationType.MANY_TO_ONE, (
fkName = CarModel.COL_CAR_MANUFACTURER,
fetchType = FetchType.LAZY)
private List<CarModel> models = Collections.emptyList();
...
}
"CarModels") (
public class CarModel {
public static final String COL_CAR_MANUFACTURER = "CarManufacturer";
"id") (
private int id;
"name") (
private String name;
"type") (
private CarType type;
fkName = COL_CAR_MANUFACTURER, fetchType = FetchType.LAZY) (
private CarManufacturer carManufacturer;
...
}
public enum CarType {
CAR, SUV, TRUCK
}
Quelques considérations :
@Table
– associe la classe à une table de base de données@PkColumn
– mappe leid
(identifiant unique) à la clé primaire de la table@Column
– mappe un membre de la classe à une colonne de table@Child
– définit la relation avec une autre entité@Child
membres annotés – configurés pour être chargés de manière paresseusetype
colonne de table – mappée à un champenum
–CarType
Pour que la classe CarManufacturer
prenne en charge les attributs définis à l’exécution (mappés à des colonnes de table définies à l’exécution), une sous-classe comme celle ci-dessous est définie :
public class CustomFieldsCarManufacturer extends CarManufacturer
implements DynamicColumnsEntity<DynamicColumn> {
private final Map<DynamicColumn, Object> customFields = new HashMap<>();
...
public void setValue(DynamicColumn column, Object value) {
customFields.put(column, value);
}
public Object getValue(DynamicColumn column) {
return customFields.get(column);
}
...
}
Cette classe stocke les attributs définis à l’exécution (champs) dans un Map
. L’interaction entre les valeurs des champs à l’exécution et l’ORM est réalisée via l’implémentation de l’interface DynamicColumnEntity
.
public interface DynamicColumnsEntity<T extends DynamicColumn> {
void setValue(T column, Object value);
Object getValue(T column);
}
setValue()
– est utilisé pour définir la valeur de la colonne définie à l’exécution lorsque celle-ci est lue à partir de la tablegetValue()
– est utilisé pour récupérer la valeur d’une colonne définie à l’exécution lorsque celle-ci est enregistrée dans la table
Le DynamicColumn
mappe les attributs définis à l’exécution sur leurs colonnes correspondantes de manière similaire à l’annotation @Column
qui mappe les membres connus à la compilation
Lors de l’exécution de l’application, le CfRunner
est exécuté. L’utilisateur est invité à saisir les noms et types des attributs personnalisés dynamiques souhaités qui enrichissent l’entité CarManufacturer
(pour simplifier, seuls les types int
et varchar
sont pris en charge).
Pour chaque paire nom-type, une commande DML est exécutée afin que les nouvelles colonnes puissent être ajoutées à la table de base de données CarManufacturer
. La méthode suivante (déclarée dans CarService
) effectue l’opération.
public void addManufacturerField(String name, String type) {
orm.getSqlQuery()
.update("alter table CarManufacturers add column " + name + " " + type);
}
Chaque attribut d’entrée est enregistré en tant que DefaultDynamicColumn
, une implémentation de référence DynamicColumn
.
Une fois que tous les attributs sont définis, deux fabricants de voitures sont ajoutés à la base de données, car l’utilisateur fournit des valeurs pour chaque attribut.
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);
La méthode ci-dessous (déclarée dans CarService
) crée effectivement l’entité via l’ORM.
public void createManufacturer(CustomFieldsCarManufacturer manufacturer, List<DynamicColumn> attributes) {
orm.update(manufacturer, new UpdateSettings<>(attributes, null));
}
La version à 2 paramètres de la méthode OrmOperations
update()
est appelée, ce qui permet de passer une instance de UpdateSettings
et de communiquer à l’ORM lors de l’exécution qu’il existe des valeurs définies à l’exécution qui doivent être persistées.
Enfin, deux modèles de voiture sont créés, correspondant à l’un des fabricants de voitures précédemment ajoutés.
CarModel mx5 = new CarModel("MX5", CarType.CAR, mazda);
CarModel cx60 = new CarModel("CX60", CarType.SUV, mazda);
carService.createModels(mx5, cx60);
La méthode ci-dessous (déclarée dans CarService
) crée effectivement les entités via l’ORM, cette fois en utilisant la méthode OrmOperations
update() pour persister des entités sans attributs dynamiques. Par souci de commodité, plusieurs entités sont créées en un seul appel.
public void createModels(CarModel... models) {
orm.update(models);
}
Comme dernière étape, l’un des fabricants créés est rechargé par son nom à l’aide d’une requête générée par l’ORM.
CarManufacturer mazda1 = carService.findManufacturerByName("Mazda", dynamicColumns);
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();
}
Quelques explications concernant la méthode définie ci-dessus méritent d’être faites.
La méthode OrmOperations
newSqlBuilder()
créé une instance de SqlBuilder
, et comme son nom l’indique, cela peut être utilisé pour générer des requêtes SQL. La méthode SqlBuilder
select()
génère la partie select from table de la requête, tandis que le reste (where, order by) doit être ajouté. La partie de la requête select peut être personnalisée en passant des instances de EntityDescriptorNodeCallback
(les détails sur EntityDescriptorNodeCallback
pourraient faire l’objet d’un futur article).
Afin de faire savoir à l’ORM que le plan est de sélectionner et de mapper des colonnes définies à l’exécution, un DynamicColumnsEntityNodeCallback
doit être passé. Avec cela, un AutoEagerLoader
est fourni afin que l’ORM comprenne qu’il doit charger de manière anticipée la liste des CarModel
s associés au fabricant. Néanmoins, cela n’a rien à voir avec les attributs définis à l’exécution, mais cela démontre comment un membre enfant peut être chargé de manière anticipée.
Conclusion
Bien qu’il y ait probablement d’autres façons de travailler avec des colonnes définies à l’exécution lorsque les données sont stockées dans des bases de données relationnelles, l’approche présentée dans cet article a l’avantage d’utiliser des colonnes de base de données standard qui sont lues/écrites à l’aide de requêtes SQL standard générées directement par l’ORM.
Il n’était pas rare que nous ayons l’occasion de discuter dans « la communauté » du asentinel-orm, des raisons qui nous ont poussés à développer un tel outil. Habituellement, à première vue, les développeurs se montraient réticents et réservés lorsqu’il s’agissait d’un ORM sur mesure, demandant pourquoi ne pas utiliser Hibernate ou d’autres implémentations JPA.
Dans notre cas, le principal moteur était le besoin d’une méthode rapide, flexible et facile pour travailler avec un nombre parfois assez important d’attributs définis à l’exécution (colonnes) pour des entités faisant partie du domaine métier. Pour nous, cela s’est avéré être la bonne voie. Les applications fonctionnent sans problème en production, les clients sont satisfaits de la rapidité et des performances atteintes, et les développeurs se sentent à l’aise et créatifs avec l’API intuitive.
Comme le projet est désormais open source, il est très facile pour toute personne intéressée de jeter un coup d’œil, de se faire une opinion objective à son sujet, et, pourquoi pas, de le dupliquer, ouvrir une PR et contribuer.
Ressources
Source:
https://dzone.com/articles/runtime-defined-columns-with-asentinel-orm