Wir schauen uns regelmäßig Datenbanken an, die neben einer Sage 100 entstanden sind. Zusatztabellen für eine Provisionsabrechnung, Schnittstellen zum Webshop, eine Serviceplanung, ein Data Warehouse für das Controlling. Manche laufen seit Jahren still vor sich hin. Andere sorgen jeden Monatsabschluss für Diskussionen.
Das Merkwürdige daran: Auf dem Papier sehen die guten und die schlechten gleich aus. Beide haben Primärschlüssel, Fremdschlüssel, eine saubere Normalisierung und passende Indizes. An den Regeln aus dem Lehrbuch lag es also nie. Es lag an einer Handvoll Entscheidungen, die in keinem Lehrbuch stehen.
Ein Teil davon lässt sich in einer Stunde reparieren. Ein anderer Teil kostet Monate, weil die Daten längst in Auswertungen, Exporten an den Steuerberater und Datensicherungen stecken. In diesem Beitrag gehen wir sechs Tabellen durch. Jede hält sich an die üblichen Regeln, und jede geht im Echtbetrieb schief. Alle stammen aus demselben (ausgedachten) Großhandel mit Kunden in Deutschland, Österreich und der Schweiz. Die Beispiele sind T-SQL für den Microsoft SQL Server.
Ein Hinweis vorab für alle, die mit der Sage 100 arbeiten: Am Standardschema der Sage änderst Du nichts, das gehört dem Hersteller. Und das ist auch gut so. Das Datenmodell der Sage 100 ist über viele Versionen gewachsen und dabei gut gealtert, weil es von Beginn an mit Bedacht entworfen wurde. Viele der Prinzipien aus diesem Beitrag findest Du dort wieder. Sobald Du aber eigene Tabellen anlegst, eine Schnittstelle baust oder Daten in ein Auswertungsmodell überführst, triffst Du genau diese Entscheidungen selbst.
Warum das Schema über alles andere entscheidet
Schlechten Code schreibt man neu. Schlechte Daten werden kopiert. Eine fehlerhafte Funktion steckt in einer Datei. Eine fehlerhafte Spalte steckt in jeder Auswertung, jedem Excel-Export, jeder Sicherung und jedem Dienst, der sie liest.
Ein falsches Schema fällt am ersten Tag nicht auf. Im Test mit ein paar hundert Belegen funktioniert alles. Es bricht im zweiten Jahr, wenn die Datenmenge zu groß ist, um sie mal eben umzubauen, und zu wichtig, um sie zu verlieren. Im ERP-Umfeld kommt etwas dazu, das in anderen Branchen fehlt: Rechnungen, Provisionen und Buchungen müssen auch nach Jahren noch genau so nachvollziehbar sein, wie sie damals entstanden sind.
Das richtige Schema bemerkt niemand. Das falsche erklärst Du die nächsten zwei Jahre in jeder Controlling-Runde.
Was jeder Leitfaden ohnehin sagt
Zuerst die Liste, die Du schon hundertmal gelesen hast:
- Jede Tabelle bekommt einen Primärschlüssel.
- Fremdschlüssel sorgen dafür, dass Verknüpfungen nicht ins Leere laufen.
- Erst normalisieren, denormalisieren nur mit gutem Grund.
- Spalten indizieren, nach denen gefiltert, verknüpft und sortiert wird.
- Den kleinsten passenden Datentyp wählen.
- Geldbeträge niemals als
floatspeichern. - Status über Nachschlagetabellen oder CHECK-Constraints abbilden, nicht als Freitext.
- Änderungszeitpunkt und Benutzer bei allem mitschreiben, was sich ändert.
- Spalten so benennen, dass der Nächste sie versteht.
Alles richtig. Die sechs Probleme unten stecken trotzdem in Schemas, die diese Liste komplett erfüllen.
1. Entscheide, was NULL bedeutet, bevor Du es erlaubst
Die erste Tabelle. Lies sie, bevor Du weiterscrollst.
CREATE TABLE dbo.Auftraege (
ID int IDENTITY PRIMARY KEY,
KundenID int NOT NULL REFERENCES dbo.Kunden(ID),
VertreterID int NULL REFERENCES dbo.Vertreter(ID),
Liefertermin date NULL,
RabattProzent decimal(5,2) NULL
);Drei Spalten erlauben NULL. Nimm Dir für jede einen Moment.
Jedes dieser NULL kann mindestens zwei verschiedene Dinge bedeuten, und die Tabelle hält keines davon fest. Eine leere VertreterID heißt: Der Kunde hat keinen Vertreter, er wird vom Innendienst betreut. Oder: Der Auftrag kam per Schnittstelle aus dem Shop, und niemand hat den Vertreter nachgetragen. Oder: Der Vertreter ist ausgeschieden, und jemand hat das Feld per Skript geleert. Ein leerer Liefertermin heißt: Noch nicht zugesagt. Oder: Selbstabholer, es gibt gar keine Lieferung. Ein leerer RabattProzent heißt: Kein Rabatt. Oder: Rabatt noch nicht ermittelt.
Jetzt die Auswirkung auf eine Abfrage. Der Vertriebsleiter will wissen, welche Vertreter dieses Jahr noch keinen Auftrag geschrieben haben.
SELECT *
FROM dbo.Vertreter
WHERE ID NOT IN (SELECT VertreterID
FROM dbo.Auftraege
WHERE Auftragsdatum >= '20260101');Das Ergebnis ist leer. Und es bleibt leer, für immer. Ein einziger Auftrag ohne Vertreter genügt: Mit der Standardeinstellung ANSI_NULLS ON kann der SQL Server nicht sagen, ob ein Wert in einer Liste fehlt, die einen unbekannten Wert enthält. Das Ergebnis jedes Vergleichs ist UNKNOWN, und UNKNOWN ist nicht TRUE. Es gibt keine Fehlermeldung. Die Auswertung sieht einfach so aus, als wären alle Vertreter fleißig gewesen. Mit NOT EXISTS statt NOT IN wäre das nicht passiert. Nur schreibt diese Variante meistens erst jemand, nachdem die falsche Zahl schon in der Vertriebsrunde lag.
Der Rabatt hat ein ähnliches Problem, nur leiser. Menge * Preis * (1 - RabattProzent / 100) ergibt NULL, sobald der Rabatt NULL ist. SUM() überspringt NULL kommentarlos. Der Umsatz ist am Ende zu niedrig, und niemand weiß, um welche Aufträge.
Die Regel, an die wir uns halten: NULL ist erlaubt, wenn die Spalte für diese Zeile nicht zutrifft. NULL ist nicht erlaubt, wenn es „wissen wir nicht" bedeutet.
ALTER TABLE dbo.Auftraege ADD
Status varchar(12) NOT NULL
CONSTRAINT DF_Auftraege_Status DEFAULT 'erfasst'
CONSTRAINT CK_Auftraege_Status
CHECK (Status IN ('erfasst', 'bestaetigt', 'geliefert', 'storniert')),
Versandart varchar(12) NOT NULL
CONSTRAINT DF_Auftraege_Versandart DEFAULT 'lieferung'
CONSTRAINT CK_Auftraege_Versandart
CHECK (Versandart IN ('lieferung', 'abholung'));
ALTER TABLE dbo.Auftraege ADD CONSTRAINT CK_Auftraege_Liefertermin
CHECK (Versandart = 'abholung'
OR Status IN ('erfasst', 'storniert')
OR Liefertermin IS NOT NULL);Jetzt hat ein leerer Liefertermin genau eine Bedeutung, und der Constraint sorgt dafür, dass es dabei bleibt. Der Zustand steht in einer Spalte statt in einem fehlenden Wert. Für den Vertreter gilt dasselbe: Wenn „kein Vertreter" ein echter fachlicher Fall ist, bekommt er einen eigenen Datensatz (etwa „Innendienst"), und die Spalte wird NOT NULL. Der Rabatt wird NOT NULL DEFAULT 0. Viele ERP-Systeme machen das bei Stammdaten genau so, und das hat seinen Grund.
2. Halte fest, wann etwas galt und wann Du es wusstest
Die nächste Tabelle ist besser als das meiste, was wir sehen. Provisionssätze von Vertretern ändern sich, also wird die Historie nicht überschrieben, sondern mit Gültigkeitszeiträumen geführt.
CREATE TABLE dbo.VertreterProvision (
ID int IDENTITY PRIMARY KEY,
VertreterID int NOT NULL REFERENCES dbo.Vertreter(ID),
ProvisionProzent decimal(5,2) NOT NULL,
GueltigAb date NOT NULL,
GueltigBis date NULL
);Was fehlt? Das ist schwerer zu sehen, deshalb eine Geschichte.
Am 20. März meldet die Geschäftsführung: Der Vertreter Süd bekommt ab dem 1. März nur noch 8 Prozent statt 10 Prozent. Die Vereinbarung wurde Ende Februar getroffen, nur hat es niemand weitergegeben. Du beendest den alten Satz zum 28. Februar und legst einen neuen ab 1. März an.
Die Provision für die erste Märzhälfte ist aber schon abgerechnet, mit 10 Prozent, und am 15. März überwiesen. Wenn Du jetzt die Provisionsabrechnung für März neu laufen lässt, steht dort 8 Prozent für den ganzen Monat. Deine Auswertung und Dein Bankkonto passen nicht mehr zusammen.
Beide Zahlen sind richtig. Die eine beantwortet, was galt. Die andere beantwortet, was Du wusstest, als Du gezahlt hast. Deine Tabelle kennt eine Zeitachse, das Geschäft läuft auf zweien.
Man nennt das fachliche Gültigkeit (valid time) und Systemzeit (transaction time). Eine Tabelle, die beides führt, heißt bitemporal. Der SQL Server bringt für die zweite Achse seit der Version 2016 ein fertiges Werkzeug mit, die temporalen Tabellen mit Systemversionsverwaltung. Die fachliche Gültigkeit pflegst Du weiter selbst, die Systemzeit schreibt der SQL Server.
CREATE TABLE dbo.VertreterProvision (
ID int IDENTITY PRIMARY KEY,
VertreterID int NOT NULL REFERENCES dbo.Vertreter(ID),
ProvisionProzent decimal(5,2) NOT NULL,
GueltigAb date NOT NULL,
GueltigBis date NULL,
ErfasstAb datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
ErfasstBis datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (ErfasstAb, ErfasstBis)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.VertreterProvisionHistorie));Jede Änderung und jede Löschung landet automatisch mit Zeitstempel in der Historientabelle. Jetzt kannst Du beide Fragen stellen:
DECLARE @Stichtag date = '20260315'; -- fachlich: Welcher Satz galt?
DECLARE @Wissen datetime2 = '20260315 12:00'; -- Systemzeit in UTC!
SELECT ProvisionProzent
FROM dbo.VertreterProvision FOR SYSTEM_TIME AS OF @Wissen
WHERE VertreterID = 17
AND GueltigAb <= @Stichtag
AND (GueltigBis IS NULL OR GueltigBis >= @Stichtag);Lies das als zwei Fragen in einer: Was galt am 15. März, und was wussten wir am 15. März? Die Antwort ist 10 Prozent, und damit lässt sich die Überweisung erklären. Ohne FOR SYSTEM_TIME kommt 8 Prozent heraus, und daraus ergibt sich die Nachberechnung.
Eine Stolperfalle, über die fast jeder einmal fällt: Die Spalten der Systemzeit stehen immer in UTC, nicht in Ortszeit. Wer AS OF mit einer deutschen Uhrzeit abfragt, liegt im Sommer zwei Stunden daneben.
Nicht jede Tabelle braucht das. Die meisten nicht. Aber Tabellen, aus denen Provisionen, Boni, Preise, Konditionen oder Abrechnungen entstehen, gehören auf den Prüfstand. Stell Dir eine Frage: Kann jemand rückwirkend korrigieren? Wenn ja, brauchst Du beide Zeitachsen. Denn bei einer Prüfung fragt niemand nur, was richtig ist. Gefragt wird, was Ihr wann gewusst habt.
3. Friere Beträge in dem Moment ein, in dem sie entstehen
Eine Belegposition. Der Kollege sagt, sie sei fertig.
CREATE TABLE dbo.Belegpositionen (
ID int IDENTITY PRIMARY KEY,
BelegID int NOT NULL REFERENCES dbo.Belege(ID),
ArtikelID int NOT NULL REFERENCES dbo.Artikel(ID),
Menge decimal(18,4) NOT NULL,
Einzelpreis decimal(19,4) NOT NULL
);Lehrbuchmäßig normalisiert. Bezeichnung und Steuerschlüssel stehen am Artikel, der Steuersatz in einer Tabelle mit Steuerschlüsseln, die Währung am Kunden, der Kurs in einer Kurstabelle. Nichts ist doppelt gespeichert. Der Rechnungsdruck holt sich alles per Join.
Und genau deshalb verändert sich die Vergangenheit. Jemand korrigiert einen Tippfehler in der Artikelbezeichnung, und alle alten Rechnungen sehen beim Nachdruck anders aus als das, was der Kunde bekommen hat. Ein Kunde wird von EUR auf CHF umgestellt, und seine Rechnungen aus dem Vorjahr sind plötzlich in Franken. Die Auswertung des Schweizer Umsatzes in Euro rechnet mit dem Kurs von heute, also ändert sich der Umsatz des letzten Quartals jeden Morgen ein bisschen.
Wer im zweiten Halbjahr 2020 die befristete Mehrwertsteuersenkung mitgemacht hat, kennt die Variante mit dem Steuersatz. Wurde der Satz damals in der Steuertabelle überschrieben statt mit einem neuen Gültigkeitszeitraum angelegt, rechneten Nachdrucke und Auswertungen alte Belege plötzlich mit dem falschen Satz.
Irgendwann fragt das Controlling, warum sich die Märzzahlen verschoben haben. Eine gute Antwort gibt es nicht, weil das Schema die Vergangenheit jedes Mal neu ausrechnet, statt sie festzuhalten.
CREATE TABLE dbo.Belege (
ID int IDENTITY PRIMARY KEY,
Belegnummer varchar(20) NOT NULL UNIQUE,
Belegdatum date NOT NULL,
KundenID int NOT NULL REFERENCES dbo.Kunden(ID),
Waehrung char(3) NOT NULL, -- ISO 4217, z. B. 'CHF'
Kurs decimal(18,8) NOT NULL, -- Einheiten Belegwährung je 1 EUR
KursQuelle varchar(20) NOT NULL -- z. B. 'EZB-Referenzkurs'
);
CREATE TABLE dbo.Belegpositionen (
ID int IDENTITY PRIMARY KEY,
BelegID int NOT NULL REFERENCES dbo.Belege(ID),
ArtikelID int NOT NULL REFERENCES dbo.Artikel(ID),
Bezeichnung nvarchar(200) NOT NULL, -- Stand bei Belegerstellung
Menge decimal(18,4) NOT NULL,
Einzelpreis decimal(19,4) NOT NULL, -- in Belegwährung
Steuersatz decimal(5,2) NOT NULL, -- z. B. 19.00, eingefroren
NettoBelegWhg decimal(19,2) NOT NULL,
NettoEUR decimal(19,2) NOT NULL -- kaufmännisch gerundet
);Achte auf die Kommentare. Ein Kurs ohne angegebene Richtung ist ein Fehler, der auf den nächsten Leser wartet. Seit dem Euro werden Kurse als Mengennotierung angegeben (1 EUR = x CHF), viele ältere Systeme und Excel-Listen rechnen aber noch mit der Preisnotierung. Wer das verwechselt, multipliziert, wo er dividieren müsste, und bei einem Kurs nahe 1 fällt es nicht einmal sofort auf.
Ja, Kurs, Bezeichnung und Steuersatz kopieren Werte, die es woanders schon gibt. Diese Kopie ist der Sinn der Sache. Ein Beleg ist ein Ereignis. Ein Ereignis speichert die Werte, wie sie waren, nicht das Rezept, um sie neu auszurechnen. Normalisierung ist eine Regel für Fakten, die wahr bleiben. Kurse, Bezeichnungen und Steuersätze bleiben nicht wahr, also greift die Regel hier nicht. ERP-Systeme wie die Sage 100 halten ihre Belege aus genau diesem Grund unabhängig von späteren Stammdatenänderungen. Bei eigenen Tabellen daneben wird es erstaunlich oft vergessen.
Welche Felder Du genau einfrierst, hängt an Deinen Abläufen. Zwei Fragen solltest Du aber im Schema beantworten und nicht jedem Report selbst überlassen: Wird die Steuer pro Position oder auf die Belegsumme gerechnet? Und welcher Betrag ist maßgeblich, der in Belegwährung oder der umgerechnete? Ein Nachsatz zum Datentyp: Neben float ist auch der SQL-Server-Typ money keine gute Wahl, weil er bei Multiplikation und Division auf vier Nachkommastellen abschneidet. decimal mit fester Genauigkeit ist für Beträge der sichere Weg.
4. Termine in der Zukunft brauchen eine Uhrzeit mit Ort
Für Ereignisse in der Vergangenheit ist ein eindeutiger Zeitpunkt richtig. Wann wurde der Beleg erfasst, wann wurde gebucht, wann ging die Zahlung ein. Daran ändert sich nichts mehr. Bei Terminen in der Zukunft ist das anders.
CREATE TABLE dbo.Wartungstermine (
ID int IDENTITY PRIMARY KEY,
VertragID int NOT NULL REFERENCES dbo.Wartungsvertraege(ID),
BeginnUtc datetime2 NOT NULL,
EndeUtc datetime2 NOT NULL
);Ein Wartungsvertrag mit einem Kunden in Wien: Jeden ersten Dienstag im Monat, 7:00 Uhr Ortszeit, bevor dort die Produktion anläuft. Die Serviceplanung erzeugt die Termine für das ganze Jahr im Voraus und speichert sie brav in UTC. Für die Sommermonate also 5:00 UTC.
Ende Oktober wird die Uhr umgestellt. Ab November stehen die Termine immer noch auf 5:00 UTC, das ist jetzt 6:00 Uhr in Wien. Der Techniker steht eine Stunde zu früh vor verschlossener Tür. Ein Zeitstempel speichert einen exakten Moment. Der Kunde hat aber keinem Moment zugestimmt, sondern „7 Uhr bei uns".
Für alles in der Zukunft speicherst Du deshalb, was die Beteiligten gemeint haben:
CREATE TABLE dbo.Wartungstermine (
ID int IDENTITY PRIMARY KEY,
VertragID int NOT NULL REFERENCES dbo.Wartungsvertraege(ID),
Datum date NOT NULL,
Beginn time(0) NOT NULL,
Ende time(0) NOT NULL,
Zeitzone varchar(50) NOT NULL -- 'W. Europe Standard Time'
);Den exakten Moment rechnest Du erst aus, wenn Du ihn brauchst, mit den Regeln, die an diesem Tag gelten. Der SQL Server kann das seit 2016 mit AT TIME ZONE:
SELECT ID,
CAST(CONCAT(Datum, ' ', Beginn) AS datetime2(0))
AT TIME ZONE Zeitzone
AT TIME ZONE 'UTC' AS BeginnUtc
FROM dbo.Wartungstermine;Die Zeitzonenregeln kommen dabei aus der Windows-Registrierung und werden mit Windows-Updates aktualisiert. Die gültigen Namen liefert sys.time_zone_info. Das ist der zweite, oft übersehene Vorteil: Staaten ändern ihre Zeitzonenregeln manchmal mit wenigen Monaten Vorlauf. Ein gespeicherter Zeitpunkt hat die Regel von damals eingebacken. Datum, Uhrzeit und Zonenname übernehmen die neue Regel, sobald der Server aktualisiert ist. Für große Datenmengen ist AT TIME ZONE allerdings nicht billig, in Massenauswertungen lohnt sich eine vorberechnete Spalte, die bei Bedarf neu befüllt wird.
Im ERP-Alltag begegnet Dir das Thema noch viel häufiger in einer harmloseren Form: Beim reinen Datum. Ein Liefertermin ist ein Kalendertag, kein Zeitpunkt. Kommt er per Schnittstelle als 2026-10-01T00:00:00+02:00 und wird irgendwo unterwegs nach UTC umgerechnet, steht in der Datenbank der 30. September, 22:00 Uhr. Abgeschnitten auf das Datum ist der Liefertermin einen Tag zu früh. Deshalb gehört ein Liefertermin in eine Spalte vom Typ date und nicht in datetime.
Und wer mit der Datenbank in die Azure SQL Database umzieht: Dort läuft die Serverzeit immer in UTC, GETDATE() liefert also UTC. Ein Beleg, der am 1. Oktober um 0:30 Uhr mit GETDATE() als Belegdatum angelegt wird, landet im September. Im Monatsabschluss fällt das auf. Vorher meistens nicht.
Der Test ist immer derselbe: Auf wessen Uhr wurde die Vereinbarung getroffen? Ein Wartungstermin um 7 Uhr ist ein Versprechen in Ortszeit, also Datum, Uhrzeit und Zone. Ein Job, der zu einem festen Zeitpunkt weltweit laufen muss, ist es nicht, also Zeitstempel. Vergangenes bekommt immer einen Zeitpunkt.
5. Prüfe Deine Eindeutigkeitsregeln, sobald Du „inaktiv" einführst
In ERP-Daten wird selten gelöscht. Kunden, Artikel und Lieferanten werden inaktiv gesetzt, weil Belege an ihnen hängen. Das ist richtig. Genauso richtig ist ein Unique-Constraint. Zusammen erzeugen sie einen Fehler.
CREATE TABLE dbo.ShopKonten (
ID int IDENTITY PRIMARY KEY,
KundenID int NOT NULL REFERENCES dbo.Kunden(ID),
EMail nvarchar(254) NOT NULL
CONSTRAINT UQ_ShopKonten_EMail UNIQUE,
Inaktiv bit NOT NULL DEFAULT 0
);Eine Einkäuferin wechselt den Arbeitgeber. Ihr Shopkonto beim alten Kunden wird inaktiv gesetzt. Ein halbes Jahr später möchte ihr neuer Arbeitgeber, ebenfalls Kunde, ihr ein Konto anlegen, mit derselben E-Mail-Adresse. Das Insert schlägt fehl. Der Unique-Constraint weiß nichts von Inaktiv und zählt die alte Zeile mit. (Dass die Prüfung dabei Groß- und Kleinschreibung ignoriert, liegt übrigens an der Sortierung der Datenbank. Bei den üblichen Collations wie Latin1_General_CI_AS ist das so und für E-Mail-Adressen auch genau richtig.)
Die zwei üblichen Notlösungen machen es schlimmer. Die E-Mail im alten Konto leeren wirft genau die Information weg, für die Du die Zeile behalten wolltest. Ein Anhängsel wie einkauf@kunde.de.inaktiv.4711 sieht aufgeräumter aus. Aber jetzt muss jede Abfrage es wieder abschneiden, jede Auswertung muss davon wissen, und eine wird es nicht tun.
Gemeint war nie „eindeutig über alle Zeilen", sondern „eindeutig unter den aktiven Konten". Genau das kann ein gefilterter Index:
ALTER TABLE dbo.ShopKonten DROP CONSTRAINT UQ_ShopKonten_EMail;
CREATE UNIQUE INDEX UX_ShopKonten_EMail_Aktiv
ON dbo.ShopKonten (EMail)
WHERE Inaktiv = 0;Das alte Konto behält seine E-Mail-Adresse und seine Historie. Derselbe Trick löst ein SQL-Server-Spezifikum, über das viele beim ersten Mal stolpern: Ein Unique-Constraint erlaubt im SQL Server genau einen NULL-Wert, nicht beliebig viele wie in anderen Datenbanken. Wer die EAN am Artikel eindeutig machen will, kann deshalb nur einen einzigen Artikel ohne EAN anlegen. Mit WHERE EAN IS NOT NULL im Index ist das erledigt.
Ein Detail aus der Doku zu gefilterten Indizes, das gern erst im Echtbetrieb auffällt: Sitzungen, die in eine Tabelle mit gefiltertem Index schreiben, brauchen bestimmte SET-Optionen, unter anderem QUOTED_IDENTIFIER ON und ANSI_NULLS ON. Moderne Treiber setzen das automatisch. Alte Schnittstellen oder gespeicherte Prozeduren, die einst mit QUOTED_IDENTIFIER OFF angelegt wurden, scheitern dann plötzlich mit Fehler 1934. Teste das, bevor Du den Index in Produktion anlegst.
Und nicht jede Regel soll sich so verhalten. Eine Artikelnummer, die einmal auf Belegen stand, solltest Du auch nach dem Inaktivsetzen nicht neu vergeben, weil sie in alten Belegen, Etiketten und Exporten weiterlebt. Dort bleibt der Unique-Constraint über alle Zeilen richtig. Entscheidend ist, dass Du beim Einführen von „inaktiv" jede Eindeutigkeitsregel der Tabelle am selben Tag anschaust. Jede einzelne hat gerade ihre Bedeutung geändert, ohne Dir Bescheid zu sagen.
6. Mach die falsche Zeile unmöglich
Zum Schluss Preise mit Gültigkeitszeitraum. Für einen Artikel in einer Preisliste darf es zu jedem Tag genau einen Preis geben, Zeiträume dürfen sich nicht überschneiden. Der Kollege prüft das im Code:
var ueberschneidung = await _db.Preise.AnyAsync(p =>
p.ArtikelID == artikelId
&& p.PreislisteID == preislisteId
&& p.GueltigAb <= neuBis
&& (p.GueltigBis == null || p.GueltigBis >= neuAb));
if (ueberschneidung)
throw new ValidationException("Preis überschneidet sich mit bestehendem Zeitraum.");Die Logik stimmt. Wo ist das Problem?
Zwei Anwender pflegen gleichzeitig Preise für denselben Artikel. Beide Prüfungen laufen, beide finden nichts, beide speichern. Kein Fehler im Code. Und dann ist da noch der Preisimport aus der Excel-Liste des Lieferanten, die Schnittstelle vom Einkauf und das Korrekturskript, das jemand an einem Freitagabend direkt im Management Studio laufen lässt. Keines davon geht durch diesen Code.
Schau Dir eine unmögliche Zeile in Deiner Produktivdatenbank an. Irgendein Code hat sie hineingelassen, und dieser Code hat versprochen, dass das nie passieren kann.
Die Datenbank kann die Zeile ablehnen, statt dem Aufrufer zu vertrauen. PostgreSQL hat dafür Exclusion Constraints. Der SQL Server hat nichts Vergleichbares, aber hier hilft eine andere Überlegung: Oft lässt sich die Tabelle so formen, dass die falsche Zeile gar nicht entstehen kann. Wenn Preise lückenlos aufeinander folgen, speicherst Du nur den Beginn. Das Ende ergibt sich aus dem nächsten Eintrag.
CREATE TABLE dbo.Preise (
ID int IDENTITY PRIMARY KEY,
ArtikelID int NOT NULL REFERENCES dbo.Artikel(ID),
PreislisteID int NOT NULL REFERENCES dbo.Preislisten(ID),
GueltigAb date NOT NULL,
Preis decimal(19,4) NOT NULL,
CONSTRAINT UQ_Preise_GueltigAb UNIQUE (ArtikelID, PreislisteID, GueltigAb)
);
GO
CREATE VIEW dbo.PreiseMitZeitraum AS
SELECT ArtikelID, PreislisteID, Preis, GueltigAb,
DATEADD(day, -1, LEAD(GueltigAb) OVER (
PARTITION BY ArtikelID, PreislisteID
ORDER BY GueltigAb)) AS GueltigBis
FROM dbo.Preise;Überschneidungen sind jetzt nicht verboten, sie sind schlicht nicht darstellbar. Zwei Preise am selben Tag scheitern am Unique-Constraint, egal ob sie aus der Maske, dem Import oder dem Freitagabend-Skript kommen. Eine befristete Aktion bekommt zwei Zeilen: Den Aktionspreis ab Beginn und den Normalpreis ab dem Tag danach.
Wenn Du echte Zeiträume brauchst, bleibt im SQL Server ein Trigger. Er läuft in derselben Transaktion wie das Insert und kann es zurückrollen. Damit er bei gleichzeitigen Zugriffen nicht dasselbe Problem hat wie der Code, muss er mit Sperrhinweisen lesen:
CREATE TRIGGER trg_Rabattaktionen_KeineUeberschneidung
ON dbo.Rabattaktionen AFTER INSERT, UPDATE
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM inserted i
JOIN dbo.Rabattaktionen r WITH (UPDLOCK, HOLDLOCK)
ON r.ArtikelID = i.ArtikelID
AND r.ID <> i.ID
AND r.GueltigAb <= i.GueltigBis
AND r.GueltigBis >= i.GueltigAb)
THROW 50001, N'Rabattaktion überschneidet sich mit bestehendem Zeitraum.', 1;
END;Das ist mehr Aufwand als eine Zeile im Schema, und unter Last kann es zu Wartezeiten oder Deadlocks führen (dazu mehr im Beitrag zu Deadlocks im SQL Server). Aber die zweite Zeile kommt nicht mehr durch.
Die einfachen Fälle sollten ohnehin selbstverständlich sein: Belegnummern mit Unique-Constraint, Mengen und Preise mit CHECK-Constraints, Fremdschlüssel auch zu den eigenen Zusatztabellen. Prüfe dabei, ob Deine Constraints auch vertrauenswürdig sind. Wer einen Constraint mit WITH NOCHECK anlegt oder nachträglich ohne WITH CHECK wieder einschaltet, lässt die vorhandenen Daten ungeprüft. Der SQL Server markiert ihn dann in sys.foreign_keys und sys.check_constraints als is_not_trusted, und der Abfrageoptimierer verlässt sich nicht mehr darauf.
Hinter jeder Validierung steht dieselbe Frage: Wer darf sich irren? Entweder benimmt sich jeder Dienst, jeder Import und jedes nächtliche Skript, das in diese Tabelle schreibt, für immer korrekt. Oder eine einzige Regel in der Datenbank hält die Linie.
Gewohnheiten, die ein Schema gesund halten
Die sechs Punkte oben betreffen Tabellen. Die folgenden betreffen die Arbeitsweise.
- Schreib die Fragen auf, bevor Du die Tabellen schreibst. Welche Auswertungen will das Controlling, was braucht der Monatsabschluss, was fragt der Steuerberater? Dann rückwärts entwerfen.
- Sprich jeden Namen einmal laut vor jemandem aus der Fachabteilung aus. Ein Spaltenname, der einen Satz Erklärung braucht, ist der falsche Name. Und wenn die Buchhaltung „Debitor" sagt, heißt die Tabelle nicht „Customer".
- Lade ein Jahr Daten, bevor Du fertig bist. Was sich bei tausend Belegpositionen gut anfühlt, zeigt seine Kosten bei zehn Millionen.
- Kommentare gehören in die Datenbank, nicht ins Wiki. Im SQL Server geht das über die erweiterte Eigenschaft
MS_Description(sp_addextendedproperty). Management Studio und viele Dokumentationswerkzeuge zeigen sie an. Die Wiki-Seite veraltet. - Prüfe eine Schemaänderung strenger als eine Codeänderung. Ein zweiter Prüfer und ein Tag Bedenkzeit sind gut investiert.
- Hol die Leute dazu, die Deine Daten lesen. Wer Berichte, Power-BI-Modelle oder Excel-Auswertungen baut, ist genauso Anwender Deines Schemas wie Deine Anwendung.
- Rolle jede Schemaänderung in mehreren Schritten aus. Neue Spalte anlegen, doppelt schreiben, Altdaten nachziehen, alte Spalte entfernen. Ein einziger Schritt lässt Dir keinen Weg zurück. Im Sage-Umfeld heißt das auch: Prüfen, ob Deine Änderung das nächste Sage-Update überlebt.
- Führe eine Liste der Entscheidungen, die sich nicht zurückdrehen lassen. Dort lohnt sich die Zeit im Review. Alles, was sich leicht ändern lässt, darf schnell gehen.
Nichts davon kostet viel Zeit. Teuer wird es, wenn Du es weglässt.
Fazit
Die Lehrbuchregeln sind notwendig, aber sie entscheiden nicht, ob ein Schema im dritten Jahr noch trägt. Das entscheiden Fragen wie: Was bedeutet dieses NULL? Kann jemand rückwirkend korrigieren? Rechnet dieser Beleg seine Vergangenheit neu aus? Code lässt sich zurückrollen. Daten nicht.
Eigene Tabellen neben der Sage 100, die auch in drei Jahren noch stimmen
Ob Zusatzmodul, Schnittstelle zum Shop oder Datenmodell für das Controlling: Wir entwerfen und prüfen Datenbankschemas rund um die Sage 100 auf genau diese Punkte, bevor die ersten zehntausend Belege darin stehen. Und wenn Deine Zahlen schon jeden Monat ein bisschen anders aussehen, finden wir heraus, woran es liegt.