asentinel-orm is een lichtgewicht ORM-tool gebouwd bovenop Spring JDBC, met name JdbcTemplate
. Het beschikt dus over de meeste functies die men zou verwachten van een basis ORM, zoals SQL-generatie, lazy loading, enz.
Door gebruik te maken van JdbcTemplate
, betekent dit dat het deelname aan Spring-beheerde transacties mogelijk maakt, en het kan eenvoudig worden geïntegreerd in elk project dat al JdbcTemplate
gebruikt als een middel om met de database te communiceren.
Sinds 2015 is asentinel-orm met succes gebruikt in verschillende applicaties en voortdurend verbeterd zoals vereist door zakelijke behoeften. In de zomer van 2024 werd het officieel een open-source project, waarvan wij geloven dat het de evolutie zal versnellen en het aantal bijdragers zal vergroten.
In dit artikel wordt een voorbeeldtoepassing gebouwd om verschillende belangrijke kenmerken van ORM te schetsen:
- Simpele configuratie
- Eenvoudige modellering van domeinen met behulp van aangepaste annotaties
- Gemakkelijk schrijven en veilig uitvoeren van eenvoudige SQL-instructies
- Automatische generatie van SQL-instructies
- Dynamisch schema (entiteiten worden verrijkt met extra runtime-attributen, opgeslagen en gelezen zonder codewijzigingen)
Toepassing
Setup
- Java 21
- Spring Boot 3.4.0
- asentinel-orm 1.70.0
- H2-database
Configuratie
Om te communiceren met de asentinel-orm en gebruik te maken van zijn functionaliteiten, is een instantie van OrmOperations
vereist.
Zoals vermeld in de JavaDoc, is dit de centrale interface voor het uitvoeren van ORM-operaties, en het is niet de bedoeling noch vereist om specifiek geïmplementeerd te worden in de clientcode.
De voorbeeldtoepassing bevat de configuratiecode om een bean van dit type te maken.
public OrmOperations orm(SqlBuilderFactory sqlBuilderFactory,
JdbcFlavor jdbcFlavor, SqlQuery sqlQuery) {
return new OrmTemplate(sqlBuilderFactory, new SimpleUpdater(jdbcFlavor, sqlQuery));
}
OrmOperations
heeft twee superinterfaces:
- SqlBuilderFactory – creëert
SqlBuilder
instanties die verder gebruikt kunnen worden om SQL-query’s te maken.SqlBuilder
kan delen van de query automatisch genereren, zoals bijvoorbeeld degene die de kolommen selecteert. De where-clausule, de order by-clausule, andere voorwaarden en de daadwerkelijke kolommen kunnen ook toegevoegd worden met behulp van methoden van de klasseSqlBuilder
. In het volgende deel van dit gedeelte wordt een voorbeeld van een doorSqlBuilder
gegenereerde query getoond. - Updater – gebruikt voor het opslaan van entiteiten in hun respectievelijke databasetabellen. Het kan invoegingen of updates uitvoeren, afhankelijk van of de entiteit nieuw is aangemaakt of al bestaat. Er bestaat een strategie-interface genaamd
NewEntityDetector
, die wordt gebruikt om te bepalen of een entiteit nieuw is. Standaard wordt deSimpleNewEntityDetector
gebruikt.
Alle queries die door de ORM worden gegenereerd, worden uitgevoerd met behulp van een SqlQueryTemplate
instantie, die verder een Spring JdbcOperations
/JdbcTemplate
nodig heeft om te functioneren. Uiteindelijk bereiken alle queries de goede oude JdbcTemplate
waardoor ze worden uitgevoerd terwijl ze deelnemen aan Spring-transacties, net als elke JdbcTemplate
directe uitvoering.
Database-specifieke SQL-constructies en logica worden geleverd via implementaties van de JdbcFlavor
interface, die verder in de meeste van de hierboven genoemde beans worden geïnjecteerd. In dit artikel, aangezien een H2-database wordt gebruikt, is een H2JdbcFlavor
implementatie geconfigureerd.
De complete configuratie van de ORM als onderdeel van de voorbeeldapplicatie is OrmConfig
.
Implementatie
Het experimentele domeinmodel dat door de voorbeeldapplicatie wordt blootgesteld, is eenvoudig en bestaat uit twee entiteiten – autofabrikanten en automodellen. Het vertegenwoordigt precies wat hun namen aanduiden, de relatie tussen hen is duidelijk: één autofabrikant kan meerdere automodellen bezitten.
Naast zijn naam is de autofabrikant verrijkt met attributen (kolommen) die dynamisch door de applicatiegebruiker tijdens runtime worden ingevoerd. Het voorbeeldgebruik is rechttoe rechtaan:
- De gebruiker wordt verzocht om de gewenste namen en types voor de dynamische attributen
- Een paar autofabrikanten worden gemaakt met concrete waarden voor eerder toegevoegde dynamische attributen, en dan
- De entiteiten worden weer geladen, beschreven door zowel de initiële als de runtime-gedefinieerde attributen
De initiële entiteiten worden gemapt met behulp van de onderstaande databasetabellen:
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)
);
De bijbehorende domeinklassen zijn versierd met ORM-specifieke annotaties om de mappings naar de bovenstaande databasetabellen te configureren.
"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
}
Een paar overwegingen:
@Table
– koppelt (associeert) de klasse aan een databasetabel@PkColumn
– koppelt deid
(unieke identificatie) aan de primaire sleutel van de tabel@Column
– koppelt een klasse lid aan een tabelkolom@Child
– definieert de relatie met een andere entiteit@Child
geannoteerde leden – geconfigureerd om lui geladen te wordentype
tabelkolom – gekoppeld aan eenenum
veld –CarType
Om de CarManufacturer
klasse ondersteuning te bieden voor runtime-gedefinieerde attributen (gekoppeld aan runtime-gedefinieerde tabelkolommen), wordt een subklasse zoals hieronder gedefinieerd:
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);
}
...
}
Deze klasse bewaart de runtime-gedefinieerde attributen (velden) in een Map
. De interactie tussen de runtime veldwaarden en de ORM wordt voldaan via de implementatie van de DynamicColumnEntity
interface.
public interface DynamicColumnsEntity<T extends DynamicColumn> {
void setValue(T column, Object value);
Object getValue(T column);
}
setValue()
– wordt gebruikt om de waarde van de runtime-gedefinieerde kolom in te stellen wanneer deze wordt gelezen uit de tabelgetValue()
– wordt gebruikt om de waarde van een runtime-gedefinieerde kolom op te halen wanneer deze wordt opgeslagen in de tabel
De DynamicColumn
maakt runtime-gedefinieerde attributen overeen met hun overeenkomstige kolommen op een vergelijkbare manier als de @Column
annotatie compileert leden met bekende tijd.
Tijdens het uitvoeren van de toepassing wordt de CfRunner
uitgevoerd. De gebruiker wordt gevraagd namen en typen in te voeren voor de gewenste dynamische aangepaste attributen die de CarManufacturer
entiteit verrijken (voor eenvoud, alleen int
en varchar
typen worden ondersteund).
Voor elk naam-typen paar wordt een DML-opdracht uitgevoerd zodat de nieuwe kolommen aan de database-tabel CarManufacturer
kunnen worden toegevoegd. De volgende methode (gedeclareerd in CarService
) voert de bewerking uit.
public void addManufacturerField(String name, String type) {
orm.getSqlQuery()
.update("alter table CarManufacturers add column " + name + " " + type);
}
Elk invoerattribuut wordt geregistreerd als een DefaultDynamicColumn
, een DynamicColumn
referentie-implementatie.
Zodra alle attributen zijn gedefinieerd, worden twee autofabrikanten toegevoegd aan de database, omdat de gebruiker waarden opgeeft voor elk attribuut.
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);
De onderstaande methode (gedeclareerd in CarService
) maakt daadwerkelijk de entiteit via de ORM.
public void createManufacturer(CustomFieldsCarManufacturer manufacturer, List<DynamicColumn> attributes) {
orm.update(manufacturer, new UpdateSettings<>(attributes, null));
}
De 2-parameter versie van de OrmOperations
update()
methode wordt aangeroepen, waardoor het mogelijk is om een UpdateSettings
instantie door te geven en bij uitvoering aan de ORM te communiceren dat er runtime-gedefinieerde waarden zijn die moeten worden opgeslagen.
Tenslotte worden twee automodellen aangemaakt, die overeenkomen met een van de eerder toegevoegde autofabrikanten.
CarModel mx5 = new CarModel("MX5", CarType.CAR, mazda);
CarModel cx60 = new CarModel("CX60", CarType.SUV, mazda);
carService.createModels(mx5, cx60);
De onderstaande methode (gedeclareerd in CarService
) maakt de entiteiten daadwerkelijk aan via de ORM, dit keer met behulp van de OrmOperations
update() methode om entiteiten op te slaan zonder dynamische attributen. Voor het gemak worden meerdere entiteiten in één keer aangemaakt.
public void createModels(CarModel... models) {
orm.update(models);
}
Als laatste stap wordt een van de aangemaakte fabrikanten terug geladen op basis van de naam met behulp van een door de ORM gegenereerde query.
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();
}
Enkele verklaringen met betrekking tot de hierboven gedefinieerde methode zijn de moeite waard om te doen.
De OrmOperations
newSqlBuilder()
methode maakt een SqlBuilder
instantie aan, en zoals de naam al aangeeft, kan dit worden gebruikt om SQL queries te genereren. De SqlBuilder
select()
methode genereert het select from table gedeelte van de query, terwijl de rest (where, order by) moet worden toegevoegd. Het select gedeelte van de query kan worden aangepast door EntityDescriptorNodeCallback
instanties door te geven (details over EntityDescriptorNodeCallback
kunnen onderwerp zijn van een toekomstig artikel).
Om de ORM op de hoogte te stellen dat het plan is om runtimegedefinieerde kolommen te selecteren en in kaart te brengen, moet een DynamicColumnsEntityNodeCallback
worden doorgegeven. Samen daarmee wordt een AutoEagerLoader
geleverd zodat de ORM begrijpt dat de lijst met CarModel
’s die geassocieerd zijn met de fabrikant gretig geladen moet worden. Dit heeft echter niets te maken met de runtimegedefinieerde attributen, maar het laat wel zien hoe een kindlid gretig geladen kan worden.
Conclusie
Hoewel er waarschijnlijk andere manieren zijn om te werken met runtimegedefinieerde kolommen wanneer gegevens zijn opgeslagen in relationele databases, heeft de aanpak die in dit artikel wordt gepresenteerd het voordeel dat er standaard databasekolommen worden gebruikt die worden gelezen/schreven met standaard SQL-query’s die rechtstreeks worden gegenereerd door de ORM.
Het was niet zeldzaam dat we in “de gemeenschap” de asentinel-orm bespraken, de redenen die we hadden om zo’n tool te ontwikkelen. Meestal waren ontwikkelaars aanvankelijk terughoudend en gereserveerd als het ging om een op maat gemaakte ORM, en vroegen zich af waarom we geen Hibernate of andere JPA-implementaties gebruikten.
In ons geval was de belangrijkste drijfveer de behoefte aan een snelle, flexibele en eenvoudige manier om soms een behoorlijk aantal runtimegedefinieerde attributen (kolommen) voor entiteiten die deel uitmaken van het bedrijfsdomein te verwerken. Voor ons bleek dit de juiste weg te zijn. De applicaties draaien soepel in productie, de klanten zijn tevreden over de snelheid en de behaalde prestaties, en de ontwikkelaars voelen zich comfortabel en creatief met de intuïtieve API.
Nu het project open source is, is het heel gemakkelijk voor iedereen die geïnteresseerd is om een kijkje te nemen, een objectieve mening te vormen en, waarom niet, het te forken, een PR te openen en bij te dragen.
Resources
Source:
https://dzone.com/articles/runtime-defined-columns-with-asentinel-orm