Generierung einer Datenbankstruktur aus C++
Juri Urbainczyk
Version 1.0 - 19. Oktober 2000
Inhaltsverzeichnis
1 Überblick *
2 Die Idee *
3 Generierungsschritte in SFD *
4 Definition des Data Dictionary *
5 C++ Datenstruktur *
6 Dynamisches Generieren von SQL-Statements *
7 Änderungshistorie *
Dieses Dokument beschreibt, wie die Information über das Datenbankschema derart abgelegt werden können, so daß eine umfangreiche relationale Datenbankstruktur aus C++ generiert werden kann, ohne die Wartbarkeit des Systems zu beeinträchtigen. Das Wesentliche dabei ist, daß bei einer Änderung der Struktur der Quelltext nicht modifiziert werden braucht und auch kein neues Kompilieren notwendig ist. Dadurch erreicht man eine höhere Qualität des Quelltextes. Die Informationen über das Datenbankschema werden an einer Stelle zentral gehalten und lassen sich besser pflegen.
Die in diesem Dokument beschriebenen Konzepte gehen auf Erfahrungen und Implementierungen im Projekt START Fare Data (SFD) zwischen 1999 und 2000 zurück.
In vielen Projekten, die mit relationalen Datenbanken arbeiten, ist es notwendig das Datenbankschema (das sogenannte Data Dictionary) vollständig neu so aufzubauen, wie es fachlich und technisch notwendig ist. Existierende Schemata können i.allg. nicht verwendet werden. Die Datenbankstruktur muß i.allg. häufig geändert werden, z.B. wegen der noch laufenden Weiterentwicklung oder wegen neuer Releases. Schnell wird z.B. die Änderung eines Spaltennamens beschlossen. Darüber hinaus muß die Struktur auch in anderen Datenbanken implementiert werden, um parallele Installationen des Systems (z.B. für den Systemtest) zu schaffen. Zusätzlich besteht häufig die Notwendigkeit, eine schon existierenden Datenbank auf leichtem Wegen neu aufbauen zu können, da z.B. die Daten durch Tests korrumpiert worden sind.
All diese Anforderungen legen nahe, daß es Sinn macht das Datenbankschema automatisch generieren zu können. Zu diesem Zweck werden oft SQL-Skripts verwendet, diese geraten jedoch schnell an ihre Grenzen. Speziell in SFD war es z.B. notwendig, die Erzeugung bestimmter Tabellen parametrisieren zu können, um unterschiedliche Tabellennamen für unterschiedliche sog. ‚Anbieter‘ zu generieren. Unter Umständen gibt es unterschiedliche Module in einem System, die unabhängig voneinander Generierungsschritte auf der Datenbank ausführen. Eine entsprechende Basisfunktionalität läßt sich nicht mit Skripten programmieren.
Wir brauchen also ein Programm, das genügend intelligent implementiert werden kann, um eine flexible Generierung des Data Dictionaries zu ermöglichen, z.B. in C++. Dieses Programm soll aber nicht jedesmal editiert werden müssen, wenn sich die Datenbankstruktur ändert. Die Folgefehler die sich bei Änderungen in alten Programmen ergeben können, vor allem bei komplexen Tabellenstrukturen, sind erheblich. Wenn möglich soll also sogar eine neue Kompilierung des Programms unterbleiben!
Es darf auch nicht vergessen werden, daß eine sinnvolle Methode gefunden werden muß, um die Informationen über das Datenbankschema an die generierende C++Funktion zu übergeben. Auch dieses Verfahren muß unabhängig vom Umfang und Inhalt der Tabellendefinitionen sein.
Im folgenden wird beschrieben, wie dieses Ziel im Projekt SFD erreicht wurde.
Im Projekt SFD gibt es verschiedene ausführbare UNIX-Programme, die Generierungsschritte auf der Datenbank durchführen. Diese sind
Alle diese Programme greifen letztendlich auf das DB-Modul zu, in dem die Basisfunktionalität für die Generierung implementiert ist.
Das Datenbankschema wird in SFD nicht in SQL-Skripten und auch nicht im Quelltext eines C++-Moduls abgelegt. Es befindet sich vollständig in einem C++-Headerfile (DB_Tables.hpp). In diesem Headerfile ist zunächst erst einmal für jede Tabelle der Name definiert. Auf die Tabellennamen darf aus dem C++ Code heraus nur über diese Konstanten zugegriffen werden, um unabhängig von Änderungen der Tabellennamen zu werden.
Als nächstes sind weitere Bezeichner für DbSpaces, Synonyme, Indexe und Constraints angegeben. Dann werden für jede Tabelle alle Spalten definiert, und zwar in der Reihenfolge, in der sie hinterher auch generiert werden sollen. Die Spalten sind in Makros eingebettet, da sie auf diese Weise einfacher ausgelesen werden können. Für jede Spalte wird eine symbolische Konstante, der physikalische Name, der Typ und die Constraints (z.B. NOT NULL) angegeben. Alle Spaltendefinitionen einer Tabelle zusammen bilden ein großes mehrzeiliges Makro, in diesem Fall ALL_OUTBOUND_COUMNS. Es nimmt zwei Argumente. Wozu diese dienen, sehen wir weiter unten. Die folgende Abbildung zeigt einen Ausschnitt aus DB_Tables.hpp für eine Tabelle.
// --- tOutbound<InstanceName>
#define ALL_OUTBOUND_COLUMNS( macro, vector ) \
macro( OUTBOUND_FAREOID_COL, "fareoid", "INTEGER", "NOT NULL", vector ) \
macro( OUTBOUND_DEPARTURETLC_COL, "ipfrom_tlc", "CHAR(3)", "NOT NULL", vector ) \
macro( OUTBOUND_ARRIVALTLC_COL, "ipto_tlc", "CHAR(3)", "NOT NULL", vector ) \
macro( OUTBOUND_FIRSTDEPART_COL, "ipfirstdepart_dt", "DATE", "NOT NULL", vector ) \
macro( OUTBOUND_LASTDEPART_COL, "iplastdepart_dt", "DATE", "NOT NULL", vector ) \
macro( OUTBOUND_CARRIER_CODE_COL, "ipcarriercode", "CHAR(2)", "NOT NULL", vector ) \
macro( OUTBOUND_RLCABINCLASS_COL, "rlcabinclass", "CHARACTER","NOT NULL", vector ) \
macro( OUTBOUND_TRAVELTYPE_COL, "iptraveltypebits", "SMALLINT", "NOT NULL", vector )\
macro( OUTBOUND_ROUTINGBITCODE_COL, "iproutingbits", "SMALLINT", "NOT NULL", vector )\
macro( OUTBOUND_PAXTYPEBITCODE_COL, "ippaxtypebits", "SMALLINT", "NOT NULL", vector )\
macro( OUTBOUND_FAREACCESSNUMBER_COL,"ipfareaccesscode","SMALLINT", "NOT NULL", vector ) \
ALL_OUTBOUND_COLUMNS( MAKE_COLUMNS, 0 );
#define MAKE_COLUMNS( macroname, columnname, ignore1, ignore2, ignore3 ) static const string macroname = columnname;
Das Makro ALL_OUTBOUND_COLUMNS existiert so für jede Tabelle, jeweils mit einem anderen Namen. Es wird gleich nach seiner Definition aufgerufen (s.o.) und ruft seinerseits das Makro MAKE_COLUMNS auf und sorgt so dafür, daß tatsächlich für jeden Spaltennamen eine Konstante vom Typ String angelegt wird.
Das Schöne an diesem Makro ALL_OUTBOUND_COLUMNS ist nun, daß es auch mit anderen Parametern aufgerufen kann, und für jede Spalte in der Tabelle eine Codezeile mit dem jeweiligen Parameter anlegt. Es existieren drei weitere Makros, welche die einzelnen Spaltennamen jeweils einem anderen String-Vektor hinzufügen:
#define
MAKE_NAME_VECTOR( macroname, columnname, coltype, colspecial, vector ) vector.push_back( columnname );
#define
MAKE_TYPE_VECTOR( macroname, columnname, coltype, colspecial, vector ) vector.push_back( coltype );
#define
MAKE_SPECIAL_VECTOR( macroname, columnname, coltype, colspecial, vector ) vector.push_back( colspecial );
Diese drei Makros werden nun verwendet, um aus den Spaltendefinitionen in DB_Tables.hpp C++-Datenstrukturen aufzubauen, die man ja benötigt, wenn man eine Generierung durchführen will.
Beim Anlegen von Tabellen wird die Struktur der Tabelle, d.h. die Namen und Typen der einzelnen Spalten, aus einer internen C++Datenstruktur gelesen. Diese Datenstruktur besteht aus Vektoren von Strings, die jeweils zu den Klassen gehören, deren Tabellen sie definieren. Die entsprechende Datenstruktur der Klasse DB::CFare sieht z.B. so aus:
vector<string> CFare::m_vecFareTableColumns;
vector<string> CFare::m_vecOutboundTableColumns;
vector<string> CFare::m_vecDetailTableColumns;
vector<string> CFare::m_vecFareTableTypes;
vector<string> CFare::m_vecDetailTableTypes;
vector<string> CFare::m_vecOutboundTableTypes;
vector<string> CFare::m_vecDetailTableColumnConstraints;
vector<string> CFare::m_vecOutboundTableColumnConstraints;
vector<string> CFare::m_vecFareTableColumnConstraints;
Wie man sieht, ist die Klasse CFare in der Datenbank über drei verschiedene Tabellen verteilt. Für jede Tabelle definiert CFare nun frei Vektoren aus Strings. Diese Vektoren sollen die Namen, die Typen und die Constraints der einzelnen Tabellenspalten speichern.
Bevor diese Datenstruktur zum Anlegen von Tabellen eingesetzt werden kann, muß sie mit Daten gefüllt werden. Das geschieht bei jeder C++-Klasse in der Methode createTableColumns() durch die Makros, die in der Datei DB_Tables.hpp definiert sind (s.o). Folgendes Beispiel zeigt die Funktion createTableColumns() der Klasse CFare, die durch den Einsatz der Makros extrem kurz sein kann:
void CFare::createTableColumns()
{
ALL_OUTBOUND_COLUMNS( MAKE_NAME_VECTOR, m_vecOutboundTableColumns );
ALL_OUTBOUND_COLUMNS( MAKE_TYPE_VECTOR, m_vecOutboundTableTypes );
ALL_OUTBOUND_COLUMNS( MAKE_SPECIAL_VECTOR,
m_vecOutboundTableColumnConstraints );
ALL_DETAIL_COLUMNS( MAKE_NAME_VECTOR, m_vecDetailTableColumns );
ALL_DETAIL_COLUMNS( MAKE_TYPE_VECTOR, m_vecDetailTableTypes );
ALL_DETAIL_COLUMNS( MAKE_SPECIAL_VECTOR,
m_vecDetailTableColumnConstraints );
ALL_FARE_COLUMNS( MAKE_NAME_VECTOR, m_vecFareTableColumns );
ALL_FARE_COLUMNS( MAKE_TYPE_VECTOR, m_vecFareTableTypes );
ALL_FARE_COLUMNS( MAKE_SPECIAL_VECTOR,
m_vecFareTableColumnConstraints );
} // createTableColumns
Jede DB Klasse enthält weiterhin eine Methode createTables(), welche die Tabelle bzw. die Tabellen anlegt, die zum Speichern der jeweiligen Klassen dienen. Zuerst prüft createTables(), ob die Vektoren mit den Spaltennamen schon gefüllt sind. Wenn nicht, wird zunächst createTableColumns() aufgerufen. Wenn die Datenstruktur gefüllt ist, geht die Funktion nun die Vektoren sequentiell durch und erzeugt daraus dynamisch das SQL-Statement. Dabei werden die Namen, Typen und die spaltenbezogenen Constraints aus der Datenstruktur gelesen und an das Statement angehängt. Um aus den immer vorhandenen drei Vektoren ein entsprechendes SQL-Statement aufzubauen, wurde eine Basisfunktion geschrieben. An dieser Stelle gebe ich einen Teil deren Implementierung wieder:
string strStatement = "CREATE TABLE " + strTableName + " (\n";
strStatement =
strStatement + vecColumnName[0] + " " + vecColumnType[0];
if (vecColumnConstraint.size() > 0) {
strStatement += " " + vecColumnConstraint[0];
}
for (int i=1; i<vecColumnName.size(); i++) {
strStatement = strStatement + \
",\n" + vecColumnName[i] + " " + vecColumnType[i];
if (vecColumnConstraint.size() > i) {
strStatement += " " + vecColumnConstraint[i];
}
}
Auf diese Weise ist die erzeugte Tabelle immer konsistent mit der Beschreibung in DB_Tables.hpp und die Spalten und Tabellennamen, die an anderen Stellen im Quelltext verwendet werden, sind immer konsistent mit der tatsächlich existierenden Tabellenstruktur.
Die Datenstrukturen, welche die Tabellendefinition enthalten, werden aber noch zu einem anderen wichtigen Zweck verwendet: zum dynamischen Erstellen von SQL-Statements, welche die Inhalte dieser Tabellen manipulieren. Auf diese Weise ist schon beim Kompilieren sichergestellt, daß die SQL-Statements nur auf Spalten zugreifen, die auch tatsächlich existieren.
Desweiteren stimmt der Typ der Spalte (sobald er abgefragt wird) immer mit dem tatsächlichen Typ überein. Beispielhaft wird im folgenden die (leicht gekürzte) Implementierung der Methode wiedergegeben, die das Statement zum Import von Daten in die Outbound-Tabelle vorbereitet.
void CFare::prepareOutboundInsertStatement( ITStatement& oStatement,
const string& strTableName )
{
static string strText = "";
static string strQuestionMark = "";
if (strText == "") {
string strSupplierName = CSupplier::getCurrentSupplier();
strText = strText + "INSERT INTO " + strTableName + " (";
strText = strText + m_vecOutboundTableColumns[0];
strQuestionMark = "?";
for (int i=1; i<m_vecOutboundTableColumns.size(); i++) {
strText = strText + ", " + m_vecOutboundTableColumns[i];
strQuestionMark = strQuestionMark + ", ?";
}
strText = strText + ") VALUES (" + strQuestionMark + ");";
tracevar( strText );
}
if (!oStatement.Prepare(strText.c_str())) {
cerrtrace << "## prepareOutboundInsertStatement(): \
Could not prepare query: " << strText << "\n";
SE_throw ( g_DB_PrepareFailed,
"Could not prepare outbound insert statement!" );
}
} // prepareOutboundInsertStatement
Datum |
Name |
Kommentar |
19.10.00 |
Juri Urbainczyk |
Erstellt |