“Noli turbare circulos meos!”
Archimedes von Syrakus
Nichts ist schlimmer als die Verwendung von alten Datenbanken, die in der Vergangenheit von Hand gepflegt wurden. Häufig ist sehr viel Kreativität gefragt, um auf der existierenden relationalen Struktur, geeignete JPA Entitäten zu erstellen. Ein weiteres Ärgernis, sind dann aber auch die Primärschlüssel der Entität, die in der Vergangenheit nicht durch einen strengen Generator erzeugt, sondern frei gewählt wurden.
Ein kurioser Fall in einer Ahnendatenbank ergab sich durch leichtfertige Ergänzungen im Datenbestand, bei denen das Geburtsjahr der Person in die ID eingeflossen ist. Plötzlich existieren große Lücken zwischen den ID Werten, weil die Ahnen Johann Seemann und Friedrich Magnus Kayser mit der ID 176701
, bzw. 178101
in die Datenbank eingefügt wurden.
Wenn diese IDs nicht anderweitig verwendet werden, dann können solche unangenehmen Ausreißer mit etwas Aufwand korrigiert werden. Manchmal muss man aber mit ihnen leben, dann muss eine Strategie her, wie ein Generator kollisionsfrei damit umgehen kann.
JPA und Hibernate bieten die Möglichkeit eigene IdentityGenerator
Klassen zu verwenden. In diesem Fall benötigen wir einen IdentityGenerator
der eine bestehende ID oder ganze ID Bereiche überspringt. Dabei gibt es zwei alternative Vorgehensweisen. Entweder schaut der IdentityGenerator
beim Start in der Datenbank und überprüft die bestehenden IDs oder wir geben die ID Bereiche vor, die übersprungen werden sollen.
Der Einfachheit geschuldet, geben wir dem IdentityGenerator
über eine Annotation die Information mit, welche ID Bereiche übersprungen werden sollen.
@Entity @GeneratedValue(generator = "generator") @GenericGenerator(name = "generator", strategy = "de.schegge.persistence.AncestorIdGenerator", parameters = @Parameter(name = "excluded", value = "160000-175099,180000-190000")) public class Ancestor { private Long id; }
Die Annotation @GenericGenerator
teil JPA mit, dass wir hier einen eigenen IdentityGenerator
verwenden wollen. Der Parameter strategy
enthält den qualifizierten Namen unserer IdentityGenerator
Implementierung. Der Parameter parameters
enthält einen einzelnen Parameter mit Namen excluded
, der die zu überspringenden Nummernkreise enthält.
Als Grundlage verwenden wir den org.hibernate.id.IncrementGenerator
. Dieser Generator wählt als Ausgangsbasis für seine Sequenz den größten ID Wert, den er beim Start vorfindet. Da ein großer Teil des Codes angepasst werden muss, erbt der AncestorIdGenerator
nicht vom IncrementGenerator
, sondern verwendet die Grundidee der Implementierung.
public class AncestorIdGenerator implements IdentifierGenerator, Configurable { private String sql; private IntegralDataTypeHolder previousValueHolder; private List<Long> legacies; @Override public synchronized Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException { if (sql != null) { initializePreviousValueHolder(session); } return getNextIdentifier(); } private Long getNextIdentifier() { Long number; do { number = previousValueHolder.makeValueThenIncrement().longValue(); } while (legacies.contains(number)); return number; } }
Das erste Attribut sql
enthält die SQL Abfrage, um den maximalen ID Wert in der enstprechenden Tabelle zu bestimmen. Das zweite Attribut previousValueHolder
wird mit diesem maximalen ID Wert initialisiert und bei aufeinanderfolgenden Aufrufen der Methode generate
immer weiter erhöht. Das dritte Attribut legacies
enthält alle Identifier, die übersprungen werden sollen. Für eine einfache Implementierung mit wenigen zu überspringenden Werten ausreichend, für andere Anforderungen nicht vermutlich nicht optimal.
Innerhalb der Methode generate
, wird die Variable previousValueHolder
beim ersten Aufruf initialisiert und danach in der Methode getNextIdentifier
, der nächste Identifier bestimmt. Hier wird der Wert von previousValueHolder
so lange inkrementiert, bis er keinem Wert aus der Liste legacies
entspricht.
Der AncestorIdGenerator
unterscheidet sich in der Bestimmung des maximale ID Wertes vom IncrementGenerator
. Der IncrementGenerator
arbeitet mit einer effizienten SQL Abfrage, die direkt den maximalen Wert liefert. Der AncestorIdGenerator
holt bei der Initialisierung alle Werte, verwirft die aus der legacies
Liste und bestimmt den maximalen Wert aus der restlichen Liste. Für kurze Tabellen eine machbare Strategie, bei längeren Tabellen muss der Ansatz abgewandelt werden.
private void initializePreviousValueHolder(SharedSessionContractImplementor session) { log.info("Fetching initial value: {}", sql); try { PreparedStatement st = session.getJdbcCoordinator().getStatementPreparer().prepareStatement(sql); try (Stream<Long> stream = queryAsStream(st, unchecked((rs, l) -> rs.getLong(1)), b -> close(session, st, b))) { Long value = stream.filter(legacies::contains).max(naturalOrder()).orElse(0L); log.info("value: {}", value); previousValueHolder.initialize(value).increment(); sql = null; log.info("First free id: {}", previousValueHolder.makeValue()); } } catch (SQLException e) { throw session.getJdbcServices().getSqlExceptionHelper().convert(e, "could not fetch initial value ", sql); } }
Die Methode verwendet hier einen Stream
Ansatz, um den Eintrag mit der höchsten ID aus dem ResultSet
auszulesen. Dazu muss die Abfrage des Wertes, wegen der SQLException
, mit unchecked((rs, l) -> rs.getLong(1))
gewrapped werden. Wie dies alles genau funktioniert ist, wird in einem zukünftigen Beitrag beleuchtet. Am Ende spuckt der Stream
die größte regulär verwendete ID aus. Enthält die Restliste keinen Eintrag, dann wird 0
verwendet. Der gefundene Wert wird inkrementiert und ist damit die nächste zu verwendende ID.
Die Liste mit den nicht regulär verwendeten ID Werten muss nun nur noch aus der Annotation in den Generator gelangen. Die Parameter werden vom Framework innerhalb der params
Parameters an die configure
Methode übergeben.
@Override public void configure(Type type, Properties params, ServiceRegistry serviceRegistry) throws MappingException { previousValueHolder = IdentifierGeneratorHelper.getIntegralDataTypeHolder(type.getReturnedClass()); sql = createSql(type, params, serviceRegistry); String excluded = ConfigurationHelper.getString("excluded", params, ""); List<Long> collect = Stream.of(excluded.split("\\s*,\\s*")).map(Arrays::asList) .flatMap(List::stream).map(x -> x.split("-")).map(this::range) .reduce(Stream.empty(), Stream::concat).collect(toList()); log.info("legacy excluded: {}", collect); } private Stream<Long> range(String[] values) { if (values.length == 1) { return Stream.of(Long.valueOf(values[0])); } return LongStream.rangeClosed(Long.parseLong(values[0]), Long.parseLong(values[1])).boxed(); }
Dort wird die Liste mit ConfigurationHelper.getString("excluded", params, "")
aus den params
extrahiert und im nachfolgenden Stream in eine Liste von Long
Werten konvertiert.
Damit ist der eigene IdentifierGenerator
auch schon implementiert und keine seine Arbeit verrichten und ungewollte Lücken schließen.