Zusammengesetzte Primärschlüssel in Delphi MVC Framework ActiveRecord
🇬🇧 English · 🇮🇹 Italiano · 🇪🇸 Español · 🇧🇷 Português
Jahrelang hat das ActiveRecord von DMVCFramework die Primärschlüssel bewusst einfach gehalten: eine einzige Spalte, keine Ausnahmen. Version 3.5 hebt diese Grenze auf.
Wenn du jemals eine relationale Datenbank von nennenswerter Größe entworfen hast, ist dir diese Tabelle begegnet:
CREATE TABLE user_roles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
PRIMARY KEY (user_id, role_id)
);
Eine Zuordnungstabelle. Ihre Identität besteht nicht aus einer Spalte, sondern aus zwei. Und jahrelang hat TMVCActiveRecord eine feste Regel durchgesetzt: genau eine Primärschlüsselspalte pro Entität. Fügst du foPrimaryKey einem zweiten Feld hinzu, hielt es dich beim Start mit einem Fehler auf.
Die Regel hielt den ORM einfach. Sie ließ aber eine sehr verbreitete Art von Tabelle außen vor: user_roles(user_id, role_id), eine Auftragsposition mit Schlüssel (order_id, line_no), ein mandantenbezogener Datensatz mit Schlüssel (tenant, code). Solche Tabellen gibt es überall.
Die Workarounds, die alle kannten
Fairerweise gab es immer einen Notausgang. Du konntest gar keinen Primärschlüssel deklarieren, zwei normale Felder mappen und sie mit RQL und Where<T> lesen wie alles andere auch. Du hast lediglich darauf verzichtet, eine Zeile über ihren Schlüssel zu adressieren: kein GetByPK, also lief jeder Lookup über einen expliziten Filter.
Oder du hast eine surrogate Autoincrement-id als Primärschlüssel hinzugefügt, mit einem UNIQUE-Constraint auf den natürlichen Spalten. ActiveRecord war zufrieden, du hattest dein CRUD per Schlüssel zurück, und die Datenbank garantierte weiterhin die Eindeutigkeit des echten Schlüssels. Die id-Spalte verbrachte danach den Rest ihres Lebens, ohne dass sie irgendjemand je abfragte, und existierte einzig, damit der ORM aufhörte zu meckern.
Beides erfüllt seinen Zweck. Unzählige gute Schemas laufen bis heute genau so. Aber keiner der beiden Wege lässt dich das Offensichtliche aussprechen: dass (user_id, role_id) der Schlüssel ist, und dass der ORM ihn auch so behandelt.
Genau deshalb hat dieses Feature so lange gewartet. Die Workarounds waren gut genug, und ein gut genug funktionierender Workaround ist der natürliche Feind der echten Lösung: solange der Notausgang funktioniert, setzt niemand die fehlende Tür ganz oben auf die Liste. Jahrelang hat eine surrogate id die Nachfrage still absorbiert, und ich habe es geschehen lassen. Diesmal war ich es leid, den Workaround zu erklären, also habe ich mich hingesetzt und ActiveRecord dazu gebracht, den natürlichen Schlüssel direkt zu mappen.
Ab 3.5 musst du dich nicht mehr entscheiden
Hier ist die gesamte Änderung an deinem Model:
[MVCTable('user_roles')]
TUserRole = class(TMVCActiveRecord)
private
[MVCTableField('user_id', [foPrimaryKey])]
fUserID: Integer;
[MVCTableField('role_id', [foPrimaryKey])]
fRoleID: Integer;
// ...
end;
Kein neues Attribut zum Merken. Du markierst beide Spalten mit foPrimaryKey, genauso wie du bereits eine markierst. Wenn du ein foPrimaryKey setzen kannst, kannst du bereits einen zusammengesetzten Schlüssel deklarieren: die Lernkurve ist eine Stufe ohne Höhe. Ab dann behandelt ActiveRecord (user_id, role_id) als Identität der Zeile, und jedes erzeugte WHERE, INSERT, UPDATE und DELETE deckt den vollständigen Schlüssel ab.
Und weil ein einzelner Wert nicht mehr auf eine Zeile zeigen kann, haben die Methoden per Schlüssel, die du kennst, eine Plural-Variante bekommen:
lRole := TMVCActiveRecord.GetByPKs<TUserRole>([1, 42]);
Dieser Aufruf, GetByPKs, ist eine Klassenmethode, und er deutet auf etwas Wissenswertes hin: das alles ist eine einzige Engine, und du bist nicht an den statischen Active-Record-Stil gebunden. DMVCFramework bringt bereits ein fertiges Repository über genau dieselben Entitäten mit, IMVCRepository<T>, in der Unit MVCFramework.Repository. Sein eigentlicher Vorteil gegenüber den Klassenmethoden ist, dass es eine Schnittstelle ist: anders als ein statischer Aufruf lässt es sich über den DI-Container direkt in deine Controller und Services injizieren. Du registrierst es einmal und lässt [MVCInject] es dem geben, der es braucht:
// Das Repository einmal registrieren, in der .dpr, bevor der Server startet
Container.RegisterType(TMVCRepository<TUserRole>, IMVCRepository<TUserRole>,
TRegistrationType.SingletonPerRequest);
// Dann dort injizieren, wo du es brauchst, in einem Controller oder Service
type
[MVCPath('/user-roles')]
TUserRolesController = class(TMVCController)
private
fRepo: IMVCRepository<TUserRole>;
public
[MVCInject]
constructor Create(UserRolesRepository: IMVCRepository<TUserRole>); reintroduce;
end;
Dieselben Entitäten, dieselbe Unterstützung für zusammengesetzte Schlüssel darunter, jetzt aber über eine Abhängigkeit erreichbar, die du in einem Test austauschen kannst, statt über einen statischen Aufruf, den du nicht austauschen kannst. Eigene Repository-Klassen zu schreiben bleibt ebenfalls völlig gültig, der Punkt ist, dass du es selten musst.
Diese Plural-Array-Methoden waren der offensichtliche Teil. Die schwierigere Frage war, wie man einem Schlüssel überhaupt einen Namen gibt, der jetzt aus mehreren Werten besteht. Neben den Array-Methoden gibt es daher nun einen anderen Weg, eine Zeile zu adressieren: du setzt jedes Schlüsselfeld per Eigenschaftsname, in beliebiger Reihenfolge, und rufst dann Load auf, eine brandneue Methode, die den Schlüssel direkt von der Entität liest statt aus einer Argumentliste. Auf dem Papier sieht das nach einer kleinen Änderung aus. Darunter steckt ein echter Paradigmenwechsel, denn zum ersten Mal besitzt die Entität ihren eigenen Schlüssel, anstatt ihn als positionsbezogenes Argument entgegenzunehmen.
Das ist der Kern, und hier endet auch der einfache Teil. Die beiden Spalten zu markieren dauert eine Minute. Die Fragen, die sich hinter dieser Minute verbergen, sind der Grund, warum der vollständige Artikel so lang ausfällt:
- Die positionsbezogenen Array-Methoden (
GetByPKs,LoadByPKs) und das eigenschaftsbasierteLoadexistieren beide aus gutem Grund: welche Falle vermeidet die eine, und wann solltest du trotzdem zur anderen greifen? - Was tun
LoadundRefresh, wenn die Zeile schlicht nicht existiert, und warum habe ich die Antwort gewählt, die sich weniger bequem anfühlt? - Können die Spalten eines Schlüssels unterschiedliche Typen haben, und wie viele davon darf die Datenbank für dich ausfüllen?
- Wie baust du beim Auto-CRUD-Controller die URL, um eine Zeile zu adressieren, deren Schlüssel aus zwei Spalten besteht, und wie werden String- oder GUID-Schlüssel darin geschrieben?
- Was ist der einzige Breaking Change der gesamten Release, und welche eng begrenzte Art von Code muss sich tatsächlich darum kümmern?
Die Antworten darauf, und auf ein paar Fragen, an die du noch nicht gedacht hast, findest du im vollständigen Artikel auf Patreon.
Eine Superkraft, die nur wenige Entwickler kennen
Das ist ein guter Moment, um auf ein Stück DMVCFramework hinzuweisen, das ich für eine stille Superkraft halte: TMVCActiveRecordController. Alle, die anfangen, ihn zu nutzen, bleiben dabei, und trotzdem haben die meisten Entwickler, mit denen ich spreche, nie gehört, dass es ihn gibt.
Was er löst, ist der repetitivste Code jeder datengetriebenen API. Für jede Tabelle würdest du sonst denselben Controller von Hand schreiben: ein GET für die Liste, ein GET per id, ein POST zum Anlegen, ein PUT zum Aktualisieren, ein DELETE, dazu Paging, Filterung und Sortierung, multipliziert mit jeder Entität im Schema. Es ist Boilerplate, den du schon hundertmal geschrieben hast und beim hunderteinsten Mal in einer Kleinigkeit falsch machen wirst, immer genau in dem einen Endpoint, der an der Code Review vorbeigerutscht ist.
Der Controller ersetzt all das durch eine Zeile:
FMVC.AddController(TMVCActiveRecordController, '/api/entities');
FMVC.AddMiddleware(TMVCActiveRecordMiddleware.Create(CON_DEF_NAME));
Ab diesem Punkt ist jede registrierte ActiveRecord-Entität eine REST-Ressource. Du bekommst vollständiges CRUD, RQL-Abfragen direkt aus der URL zum Filtern, Sortieren und Paginieren der Ergebnisse, und eine für dich generierte Swagger-Beschreibung. Kein Controller pro Entität, kein DTO pro Entität, keine Route pro Entität.
Was ihn in einer echten Anwendung sicher einsetzbar macht, ist, dass er kein dummer Durchreicher ist, der Zeilen hinter dem Rücken des ORM in die Datenbank und wieder heraus schaufelt. Der Controller führt deine Entitäten durch ihren normalen Lebenszyklus, sodass jede Business-Logik, die du in die Entität gelegt hast, weiterhin läuft: die Validierung, die Hooks OnBeforeInsert und OnBeforeUpdate, die berechneten und schreibgeschützten Felder, die Serialisierungsregeln. Ein Wert, den die Entität ablehnt, wird über HTTP genauso entschieden abgelehnt, wie er es aus Delphi-Code heraus wäre. Du stellst dein Model bereit, du umgehst es nicht.
Kombiniere das mit dem neuen Entitäten-Generator, und die Rechnung wird schon fast absurd. Du richtest den Generator auf eine bestehende Datenbank und er schreibt dir die TMVCActiveRecord-Klassen, Tabelle für Tabelle; du registrierst den Controller einmal, und ein Schema mit tausenden Tabellen wird in etwa der Zeit, die man zum Kaffeekochen braucht, zu einer RESTful API. Die Grenze ist klar: eine Tabelle, die die Wurzel eines Aggregats ist, mit Kindern oder anderen Beziehungen, deren Konsistenz gewahrt bleiben muss (denk an einen Rechnungskopf mit seinen Rechnungspositionen), sollte nicht auf diese Weise Zeile für Zeile bereitgestellt werden. Sie gehört hinter ihre Aggregate Root, so wie es das Domain-Driven Design (das berühmte Buch von Eric Evans) nahelegt, damit die Wurzel die Invarianten des Aggregats durchsetzen kann. Solche Fälle verdienen weiterhin einen eigenen, von Hand geschriebenen Controller. Aber die meisten Schemas bestehen überwiegend aus einfachen, unabhängigen Tabellen, und für jede einzelne davon liefert dir dieser Low-Code-Weg eine funktionierende, validierte API bei nahezu null Aufwand.
Und genau hier mussten sich die zusammengesetzten Schlüssel ihren Platz verdienen. Ein Auto-CRUD-Controller ist nur so allgemein wie die Schlüssel, die er adressieren kann, also hätte ein Feature, das bei einspaltigen Schlüsseln stehen bleibt, die Zuordnungstabellen ausgerechnet aus dem Teil des Frameworks ausgeschlossen, dessen ganze Aufgabe es ist, jede Entität gleich zu behandeln.
Lies den vollständigen Walkthrough
Ich habe das Ganze ausführlich aufgeschrieben, mit dem Code, der Begründung hinter jeder Entscheidung und den Tests, mit denen es ausgeliefert wird (SQLite, Firebird und PostgreSQL, Win32 und Win64), als Deep-Dive-Artikel für die DelphiMVCFramework-Abonnenten auf Patreon.
Wenn du Delphi-Backends mit DMVCFramework baust, dort stecken die Details. Ein Abonnement öffnet dir außerdem den Rest des Premium-Materials: Deep-Dive-Artikel wie diesen, Videos, die ausführlichen Bücher und einen Rabatt auf die nächste Ausgabe des offiziellen DMVCFramework-Leitfadens. Und es ist auch das, was die Weiterentwicklung des Frameworks am Laufen hält.
👉 Zusammengesetzte Primärschlüssel in DMVCFramework ActiveRecord: vollständiger Artikel auf Patreon

Comments
comments powered by Disqus