JPA Tabellen mit reservierten Nummernkreisen

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