Holger's Code · September 2, 2026

Delphi und PostgreSQL von Grund auf: eine FireDAC-CLI in vier Units

Eine vollständige, kompilierende Delphi-Kommandozeilenanwendung, die einen PostgreSQL-Container startet, ihre eigene Datenbank anlegt, zwei Tabellen erzeugt, Zeilen einfügt und wieder ausliest — jede Unit, jeder Windows-Befehl und die eine DLL, über die fast jeder stolpert.

By Dr. Holger Flick

Die meisten Datenbankbeispiele hören genau da auf, wo es interessant wird. Man bekommt ein Formular mit einer daraufgelegten TFDConnection, ein fest verdrahtetes Passwort, ein Grid und einen Screenshot — und für alles, was daraus ein echtes Programm macht, ist man dann allein: wo die Einstellungen leben, wer die Datenbank anlegt, was passiert, wenn der Server nicht läuft, und wie man das Ganze baut, ohne in der IDE herumzuklicken.

Machen wir es also einmal vollständig. Am Ende dieses Beitrags läuft ein PostgreSQL-Server in einem Container, und eine Delphi-Kommandozeilenanwendung aus vier Quelldateien legt ihre eigene Datenbank an, erzeugt zwei Tabellen, fügt Zeilen innerhalb einer Transaktion ein, liest sie mit einem Join wieder aus und gibt einen formatierten Bericht aus. Jede Datei steht hier vollständig. Jeder Befehl lässt sich direkt in ein Windows-Terminal einfügen.

Zwischen Ihnen und dieser Ausgabe steht ein ehrlicher Haken — eine einzige DLL, die der Container nicht mitliefert — und er bekommt einen eigenen Abschnitt statt einer Fußnote, denn er ist es, der die meisten beim ersten Versuch stoppt.

Das Versprechen: rund 480 Zeilen Object Pascal, eine compose.yaml und vier Befehle, und Sie beherrschen den gesamten Stack ab der Portnummer.

Was wir bauen

Das fertige Programm ist ein kleines Kommandozeilenwerkzeug namens WidgetShop, und es tut vier Dinge, eines pro Befehl. Wer die Form vorher kennt, liest den folgenden Code deutlich schneller.

WidgetShop setup    create the database and the tables
WidgetShop seed     insert sample customers and orders
WidgetShop list     print every order, biggest first
WidgetShop reset    drop the tables again

Vier bewegliche Teile müssen dafür zusammenpassen, und es lohnt sich, sie zu benennen, bevor wir eines davon bauen.

Die vier beweglichen Teile: eine Compose-Datei startet den Server, libpq schlägt die Brücke, der Port ist die Tür

Von links nach rechts gelesen: Ihr Programm ruft FireDAC, FireDAC ruft libpq.dll, und libpq spricht über den veröffentlichten Port mit PostgreSQL im Container. Die Compose-Datei über dem Container zaubert die gesamte rechte Seite herbei. Drei dieser vier Kästen bekommen Sie geschenkt. Der mittlere — die Client-Bibliothek — ist das Stück, das Sie selbst dorthin legen müssen, und genau deshalb bekommt es unten einen eigenen Schritt.

Falls Container für Sie neu sind: die Docker-Serie auf dieser Seite führt durch die Installation von Docker Desktop und den ersten Container, und Teil 2 zeigt den kürzestmöglichen Postgres-und-FireDAC-Rundlauf mit einem einzigen docker run. Dieser Beitrag ist die Version, die Sie tatsächlich in ein Repository einchecken würden.

Schritt 1 — PostgreSQL mit einer Compose-Datei starten

Statt eines Absatzes voller docker run-Flags, der nur in Ihrer Shell-History lebt, wird der Server einmal in einer Datei beschrieben, die neben dem Quelltext liegt. Legen Sie einen Ordner für das Projekt an und speichern Sie darin Folgendes als compose.yaml.

# PostgreSQL for the WidgetShop example.
#
#   docker compose up -d      start the server in the background
#   docker compose ps         see whether it is healthy
#   docker compose down       stop it, keep the data
#   docker compose down -v    stop it and delete the data as well
#
# The application database ("widgetshop") is NOT created here on purpose --
# "WidgetShop.exe setup" creates it, so you can watch it happen.
 
services:
  db:
    image: postgres:17
    container_name: widgetshop-db
    environment:
      # The only variable the official image requires.
      POSTGRES_PASSWORD: secret
    ports:
      # host:container -- this one line is what lets Delphi reach the server.
      - "5432:5432"
    volumes:
      # Named volume: the data survives "docker compose down".
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      # pg_isready ships inside the image; "healthy" means "accepting connections".
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped
 
volumes:
  pgdata:

Das meiste davon kommt Ihnen bekannt vor, wenn Sie Compose schon einmal begegnet sind, aber drei Zeilen verdienen eine ordentliche Einführung. POSTGRES_PASSWORD ist die eine Einstellung, auf der das offizielle postgres-Image besteht — seine Dokumentation wird da deutlich: "This environment variable is required for you to use the PostgreSQL image. It must not be empty or undefined." Der ports-Eintrag veröffentlicht Container-Port 5432 auf Port 5432 Ihres Rechners, und genau diese eine Zuordnung ist die gesamte Brücke zwischen Delphi und der Datenbank. Das benannte Volume pgdata hält Ihre Zeilen über ein docker compose down hinweg am Leben, und es wird an genau dem Pfad eingehängt, den das Image verlangt.

Über diesen Pfad lohnt sich eine Anmerkung, denn er hat sich kürzlich geändert, und ein veraltetes Copy-and-paste beißt Sie dann.

Der healthcheck-Block ist der kleine Luxus, der diese Datei erst richtig wertvoll macht. Er führt alle fünf Sekunden pg_isready aus — ein Werkzeug, das im Image mitgeliefert wird und laut PostgreSQL selbst "is a utility for checking the connection status of a PostgreSQL database server". Sein Exit-Status ist die ganze Antwort: 0 heißt, der Server nimmt Verbindungen an, 1 heißt, er weist sie noch ab — genau das, was eine Datenbank in den ersten ein bis zwei Sekunden nach dem Start tut. Dockers Compose-Dateireferenz beschreibt healthcheck als Deklaration "a check that's run to determine whether or not the service containers are 'healthy'", und dieser Status macht aus „der Container läuft" das viel nützlichere „die Datenbank wird Ihnen auch antworten".

Hochfahren

Ist die Datei gespeichert, startet ein einziger Befehl alles und legt dabei Netzwerk und Volume gleich mit an.

docker compose up -d

Prüfen, ob er gesund ist

Statt zu raten, fragen Sie Compose nach dem Status der Dienste in diesem Stack.

docker compose ps

Sie suchen eine STATUS-Spalte, in der Up ... (healthy) steht und nicht Up ... (health: starting). Dieses Wort healthy ist der Healthcheck von oben, der zurückmeldet, und es ist Ihr Signal, dass ein Verbindungsversuch gelingen und nicht in einen Timeout laufen wird.

!!! tip "Auf „healthy" warten, in einer Zeile" Wenn Sie das skripten, ist depends_on der eingebaute Weg zu warten: Compose "guarantees dependency services marked with service_healthy are 'healthy' before starting a dependent service." Das wird in dem Moment wichtig, in dem ein zweiter Dienst dazukommt, der mit dieser Datenbank spricht — und genau das ist das Terrain von Teil 4 der Docker-Serie.

Schritt 2 — Das eine, was der Container nicht mitliefert

Hier ist der Haken, und es ist besser, ihm jetzt zu begegnen als als kryptischer Fehler um drei Uhr nachts. FireDACs nativer PostgreSQL-Treiber spricht das PostgreSQL-Protokoll nicht selbst. Er delegiert an libpq, PostgreSQLs offizielle C-Client-Bibliothek, die unter Windows als libpq.dll plus eine Handvoll Abhängigkeiten daherkommt. Der Container hat Ihnen den Server gegeben. Ihr Delphi-Programm läuft auf dem Host, und es braucht den Client.

Die DLLs zu besorgen ist eine einmalige Pflichtübung. Sie stecken im normalen PostgreSQL-Download für Windows, und Sie müssen dafür keinen Server installieren: Starten Sie das Installationsprogramm, wählen Sie nur die Komponente command-line tools aus und kopieren Sie dann libpq.dll und ihre Begleiter neben Ihre ausführbare Datei oder in den PATH. Sind sie einmal am Platz, denken Sie nie wieder daran.

Ob Ihr Rechner sie schon hat, fragen Sie Windows direkt:

where libpq.dll

Keine Ausgabe heißt, sie sind noch nicht im PATH. Das ist ein behebbarer, einmaliger Zustand — kein Rätsel.

Schritt 3 — Das Projekt, vier Dateien nacheinander

Mit laufendem Server und vorhandener Client-Bibliothek ist die Delphi-Seite ganz gewöhnliches FireDAC. So ist der Code aufgeteilt, und das aus gutem Grund.

DateiWofür sie zuständig ist
App.Config.pasWo der Server steht und als wer wir uns anmelden
App.Database.pasDatenbank anlegen, Verbindungen öffnen, das Schema
App.Shop.pasZeilen schreiben und wieder auslesen
WidgetShop.dprBefehlsauswertung und jede Zeile Konsolenausgabe

Diese Aufteilung ist kein Selbstzweck. Sie gibt dem Programm eine einzige Abhängigkeitsrichtung — und das ist die Eigenschaft, die Code später leicht änderbar macht.

Jede Unit hängt nur nach unten — das Programm kennt SQL, das SQL kennt das Programm nicht

Folgen Sie den Pfeilen, und eine Regel fällt heraus: nichts zeigt jemals nach oben. App.Shop weiß nicht, dass es eine Konsole gibt — an dem Tag, an dem daraus ein VCL-Formular oder ein REST-Dienst wird, ändert sich diese Unit überhaupt nicht. App.Config sitzt unten, weil alle wissen müssen, wo der Server steht, und niemand irgendetwas über App.Config wissen muss.

App.Config.pas — die Einstellungen, an genau einer Stelle

Die erste Unit beantwortet eine einzige Frage: Wo ist der Server, und wer sind wir? Sie in eine eigene Datei zu legen bedeutet, dass es genau eine Stelle gibt, an der man nachsieht, wenn eine Verbindung scheitert.

unit App.Config;
 
{ Connection settings for the PostgreSQL server started by compose.yaml.
 
  Everything the application needs to find the database lives here and nowhere
  else, so there is exactly one place to look when a connection fails. }
 
interface
 
uses
  FireDAC.Comp.Client;
 
type
  TAppConfig = record
    Host: string;
    Port: string;
    UserName: string;
    Password: string;
    /// The database this application owns and creates.
    Database: string;
    /// The database we log in to in order to create the one above.
    MaintenanceDatabase: string;
    /// Read the settings, letting environment variables override the defaults.
    class function Load: TAppConfig; static;
  end;
 
/// Point AConnection at ADatabase on the configured server.
procedure ConfigureConnection(AConnection: TFDConnection;
  const AConfig: TAppConfig; const ADatabase: string);
 
implementation
 
uses
  System.SysUtils;
 
function EnvOrDefault(const AName, ADefault: string): string;
begin
  Result := GetEnvironmentVariable(AName);
  if Result = '' then
    Result := ADefault;
end;
 
class function TAppConfig.Load: TAppConfig;
begin
  Result.Host := EnvOrDefault('WIDGETSHOP_HOST', 'localhost');
  Result.Port := EnvOrDefault('WIDGETSHOP_PORT', '5432');
  Result.UserName := EnvOrDefault('WIDGETSHOP_USER', 'postgres');
  Result.Password := EnvOrDefault('WIDGETSHOP_PASSWORD', 'secret');
  Result.Database := EnvOrDefault('WIDGETSHOP_DB', 'widgetshop');
  // Always present on a fresh PostgreSQL server, and never dropped.
  Result.MaintenanceDatabase := 'postgres';
end;
 
procedure ConfigureConnection(AConnection: TFDConnection;
  const AConfig: TAppConfig; const ADatabase: string);
begin
  AConnection.Params.Clear;
  AConnection.Params.DriverID := 'PG';
  AConnection.Params.Values['Server'] := AConfig.Host;
  AConnection.Params.Values['Port'] := AConfig.Port;
  AConnection.Params.Values['Database'] := ADatabase;
  AConnection.Params.Values['User_Name'] := AConfig.UserName;
  AConnection.Params.Values['Password'] := AConfig.Password;
  // A console application must never pop up a login dialog: fail loudly instead.
  AConnection.LoginPrompt := False;
end;
 
end.

Drei Details leisten hier echte Arbeit. DriverID := 'PG' ist die Art, wie FireDAC den PostgreSQL-Treiber auswählt — jede Engine hat eine kurze Kennung, und die Parameternamen drumherum (Server, Port, Database, User_Name, Password) sind die, die Embarcadero für die Verbindung zu PostgreSQL dokumentiert. LoginPrompt := False ist wichtiger, als es aussieht: Eine Konsolenanwendung, die einen Anmeldedialog zeigen will, hängt entweder oder stürzt ab, und mit einer klaren Exception zu scheitern ist deutlich freundlicher. Und EnvOrDefault bedeutet, dass dieselbe kompilierte Anwendung mit set WIDGETSHOP_HOST=... auf den Server eines Kollegen zeigen kann, statt neu gebaut zu werden.

Beachten Sie außerdem, dass TAppConfig zwei Datenbanknamen trägt. Der zweite ist der Schlüssel zur nächsten Unit.

App.Database.pas — eine Datenbank anlegen, mit der man nicht verbunden ist

Jetzt das Henne-Ei-Problem: Um CREATE DATABASE widgetshop auszuführen, muss man mit PostgreSQL verbunden sein — aber man kann sich nicht mit widgetshop verbinden, weil es die noch nicht gibt. Der Ausweg ist eine Datenbank, die immer da ist.

Zwei Verbindungen, zwei Aufgaben: sich an der Wartungsdatenbank anmelden, um die eigene anzulegen

Das Bild zeigt den ganzen Trick: Die erste Verbindung geht an postgres, eine Datenbank, die jeder Server hat und niemand löscht, nur damit wir irgendwo stehen können, während wir CREATE DATABASE absetzen. Die zweite Verbindung geht an die gerade angelegte Datenbank, und alles Weitere passiert dort. Hier ist die Unit, die das erledigt.

unit App.Database;
 
{ Everything that brings the database itself into existence: creating it,
  opening a connection to it, and creating or dropping its tables. }
 
interface
 
uses
  FireDAC.Comp.Client,
  App.Config;
 
/// Create the application database unless it is already there.
/// Returns True when this call is the one that created it.
function EnsureDatabase(const AConfig: TAppConfig): Boolean;
 
/// Open a connection to the application database. The caller owns the result.
function OpenAppConnection(const AConfig: TAppConfig): TFDConnection;
 
/// Create the customers and orders tables. Safe to run repeatedly.
procedure CreateSchema(AConnection: TFDConnection);
 
/// Drop both tables: orders first, because it references customers.
procedure DropSchema(AConnection: TFDConnection);
 
implementation
 
uses
  System.SysUtils;
 
{ A database or table name cannot be passed as a query parameter, so it has to
  be pasted into the SQL text. That is exactly the shape SQL injection takes,
  so we refuse anything that is not a plain lower-case identifier. }
procedure ValidateIdentifier(const AName: string);
var
  C: Char;
begin
  if AName = '' then
    raise Exception.Create('Database name must not be empty.');
  for C in AName do
    if not CharInSet(C, ['a'..'z', '0'..'9', '_']) then
      raise Exception.CreateFmt(
        'Refusing to build SQL from %s: lower-case letters, digits and ' +
        'underscores only.', [QuotedStr(AName)]);
end;
 
function DatabaseExists(AConnection: TFDConnection; const AName: string): Boolean;
begin
  // pg_database is PostgreSQL's own catalog of every database on the server.
  Result := AConnection.ExecSQLScalar(
    'SELECT count(*) FROM pg_database WHERE datname = :name', [AName]) > 0;
end;
 
function EnsureDatabase(const AConfig: TAppConfig): Boolean;
var
  Conn: TFDConnection;
begin
  ValidateIdentifier(AConfig.Database);
 
  Conn := TFDConnection.Create(nil);
  try
    // You cannot connect to a database that does not exist yet, so we log in
    // to the maintenance database and create ours from there.
    ConfigureConnection(Conn, AConfig, AConfig.MaintenanceDatabase);
    Conn.Connected := True;
 
    Result := not DatabaseExists(Conn, AConfig.Database);
    if Result then
      Conn.ExecSQL('CREATE DATABASE ' + AConfig.Database);
  finally
    Conn.Free;
  end;
end;
 
function OpenAppConnection(const AConfig: TAppConfig): TFDConnection;
begin
  Result := TFDConnection.Create(nil);
  try
    ConfigureConnection(Result, AConfig, AConfig.Database);
    Result.Connected := True;
  except
    Result.Free;
    raise;
  end;
end;
 
procedure CreateSchema(AConnection: TFDConnection);
begin
  AConnection.ExecSQL(
    'CREATE TABLE IF NOT EXISTS customers ('    +
    '  id         SERIAL      PRIMARY KEY,'     +
    '  name       TEXT        NOT NULL,'        +
    '  email      TEXT        NOT NULL UNIQUE,' +
    '  created_at TIMESTAMPTZ NOT NULL DEFAULT now())');
 
  AConnection.ExecSQL(
    'CREATE TABLE IF NOT EXISTS orders ('  +
    '  id          SERIAL        PRIMARY KEY,' +
    '  customer_id INTEGER       NOT NULL'     +
    '                REFERENCES customers(id) ON DELETE CASCADE,' +
    '  product     TEXT          NOT NULL,'    +
    '  quantity    INTEGER       NOT NULL CHECK (quantity > 0),' +
    '  unit_price  NUMERIC(10,2) NOT NULL,'    +
    '  ordered_at  TIMESTAMPTZ   NOT NULL DEFAULT now())');
end;
 
procedure DropSchema(AConnection: TFDConnection);
begin
  AConnection.ExecSQL('DROP TABLE IF EXISTS orders');
  AConnection.ExecSQL('DROP TABLE IF EXISTS customers');
end;
 
end.

Ein paar Dinge darin lohnen einen zweiten Blick. DatabaseExists fragt pg_database ab, PostgreSQLs eigenen Katalog aller Datenbanken auf dem Server — ein deutlich besserer Test, als sich zu verbinden und den Fehlschlag abzufangen. ValidateIdentifier existiert, weil ein Datenbankname zu den wenigen Dingen gehört, die SQL nicht als Parameter annimmt: Er muss in den Anweisungstext hineinkonkateniert werden, und Konkatenation ist genau der Ort, an dem Injection lebt — also wird der Name zuerst gegen einen bewusst langweiligen Zeichenvorrat geprüft. Und OpenAppConnection gibt die Verbindung frei, falls Connected := True eine Exception wirft, damit ein nicht erreichbarer Server nicht auch noch ein Objekt leckt.

Das Schema selbst ist klein, aber kein Spielzeug. SERIAL gibt jeder Tabelle einen automatisch hochzählenden ganzzahligen Schlüssel. REFERENCES customers(id) ON DELETE CASCADE lässt die Datenbank selbst durchsetzen, dass eine Bestellung zu einem echten Kunden gehört, und räumt Bestellungen auf, wenn ein Kunde verschwindet. CHECK (quantity > 0) weist Unsinn ab, bevor er überhaupt gespeichert wird. NUMERIC(10,2) ist der richtige Typ für Geld — es speichert Dezimalstellen exakt, was ein Fließkommatyp nicht versprechen kann.

Unter EnsureDatabase liegt eine PostgreSQL-Regel, die eine Fehlermeldung erklärt, der Sie irgendwann begegnen werden.

App.Shop.pas — Zeilen schreiben und wieder auslesen

Diese Unit fasst die eigentlichen Daten an, und sie enthält mit Absicht überhaupt keine Ausgabe. Sie gibt Records zurück; das Programm entscheidet, was damit geschieht.

unit App.Shop;
 
{ The data the application actually cares about: writing sample rows and
  reading them back. No Writeln in here - this unit returns data, the program
  decides how to show it. }
 
interface
 
uses
  FireDAC.Comp.Client;
 
type
  /// One order joined to the customer who placed it.
  TOrderLine = record
    OrderId: Integer;
    Customer: string;
    Product: string;
    Quantity: Integer;
    UnitPrice: Currency;
    LineTotal: Currency;
  end;
 
/// Insert two customers and their orders. Returns the number of orders written.
function SeedSampleData(AConnection: TFDConnection): Integer;
 
/// Read every order together with its customer, most valuable line first.
function FetchOrderLines(AConnection: TFDConnection): TArray<TOrderLine>;
 
implementation
 
uses
  System.Generics.Collections,
  FireDAC.Stan.Param;
 
function InsertCustomer(AConnection: TFDConnection;
  const AName, AEmail: string): Integer;
var
  Qry: TFDQuery;
begin
  Qry := TFDQuery.Create(nil);
  try
    Qry.Connection := AConnection;
    // RETURNING hands the generated id straight back, so there is no second
    // round trip to ask "what number did you just give me?".
    Qry.SQL.Text :=
      'INSERT INTO customers (name, email) VALUES (:name, :email) RETURNING id';
    Qry.ParamByName('name').AsString := AName;
    Qry.ParamByName('email').AsString := AEmail;
    Qry.Open;
    Result := Qry.Fields[0].AsInteger;
  finally
    Qry.Free;
  end;
end;
 
procedure InsertOrder(AConnection: TFDConnection; ACustomerId: Integer;
  const AProduct: string; AQuantity: Integer; AUnitPrice: Currency);
begin
  AConnection.ExecSQL(
    'INSERT INTO orders (customer_id, product, quantity, unit_price) ' +
    'VALUES (:customer_id, :product, :quantity, :unit_price)',
    [ACustomerId, AProduct, AQuantity, AUnitPrice]);
end;
 
function SeedSampleData(AConnection: TFDConnection): Integer;
var
  AdaId, GraceId: Integer;
begin
  // Six inserts that belong together: either all of them land, or none do.
  AConnection.StartTransaction;
  try
    AdaId := InsertCustomer(AConnection, 'Ada Lovelace', 'ada@example.com');
    GraceId := InsertCustomer(AConnection, 'Grace Hopper', 'grace@example.com');
 
    InsertOrder(AConnection, AdaId, 'Analytical Engine Gear', 4, 129.50);
    InsertOrder(AConnection, AdaId, 'Punch Card Set', 12, 3.75);
    InsertOrder(AConnection, GraceId, 'Nanosecond Wire', 1, 11.80);
    InsertOrder(AConnection, GraceId, 'COBOL Manual', 2, 42.00);
 
    AConnection.Commit;
    Result := 4;
  except
    AConnection.Rollback;
    raise;
  end;
end;
 
function FetchOrderLines(AConnection: TFDConnection): TArray<TOrderLine>;
var
  Qry: TFDQuery;
  Lines: TList<TOrderLine>;
  Line: TOrderLine;
begin
  Lines := TList<TOrderLine>.Create;
  try
    Qry := TFDQuery.Create(nil);
    try
      Qry.Connection := AConnection;
      Qry.Open(
        'SELECT o.id, c.name AS customer, o.product, o.quantity, ' +
        '       o.unit_price, o.quantity * o.unit_price AS line_total ' +
        'FROM orders o ' +
        'JOIN customers c ON c.id = o.customer_id ' +
        'ORDER BY line_total DESC, o.id');
 
      while not Qry.Eof do
      begin
        Line.OrderId   := Qry.FieldByName('id').AsInteger;
        Line.Customer  := Qry.FieldByName('customer').AsString;
        Line.Product   := Qry.FieldByName('product').AsString;
        Line.Quantity  := Qry.FieldByName('quantity').AsInteger;
        Line.UnitPrice := Qry.FieldByName('unit_price').AsCurrency;
        Line.LineTotal := Qry.FieldByName('line_total').AsCurrency;
        Lines.Add(Line);
        Qry.Next;
      end;
    finally
      Qry.Free;
    end;
    Result := Lines.ToArray;
  finally
    Lines.Free;
  end;
end;
 
end.

Vier Techniken aus dieser Unit lohnen sich für Ihren eigenen Code. Jeder Wert geht als Parameter hinein — die Platzhalter :name und :customer_id — damit der Treiber sauber typisierte Werte schickt, statt dass Sie Zeichenketten zusammenkleben und hoffen, dass niemand O'Brien heißt. RETURNING id bittet PostgreSQL, den gerade erzeugten Schlüssel als Teil derselben Anweisung zurückzugeben, und genau deshalb ruft InsertCustomer Open statt ExecSQL auf: Eine Anweisung mit RETURNING liefert eine Ergebnismenge. Das gesamte Befüllen läuft in einer Transaktion, damit ein Fehlschlag beim fünften Insert Sie nicht mit zwei Kunden und drei Bestellungen zurücklässt; Rollback und danach raise macht die Arbeit rückgängig und lässt den Aufrufer trotzdem sehen, was schiefging. Und das SELECT erledigt seine Rechnerei und seine Sortierung in der Datenbanko.quantity * o.unit_price AS line_total berechnet PostgreSQL, und ORDER BY line_total DESC sortiert nach dieser berechneten Spalte, Arbeit, die Delphi nie wiederholen muss.

Die Trennung zwischen ExecSQL und Open bringt Einsteiger am häufigsten ins Straucheln, deshalb klar gesagt: ExecSQL ist für Anweisungen, die etwas ändern und keine Zeilen liefern, und TFDQuery.Open für Anweisungen, die Ihnen eine Ergebnismenge zum Durchlaufen geben.

WidgetShop.dpr — das Programm selbst

Die letzte Datei ist die einzige, die mit einem Menschen spricht. Sie wertet den Befehl aus, ruft in die Units oben hinein und gibt aus.

program WidgetShop;
 
{ A tiny PostgreSQL client for the command line.
 
    WidgetShop setup    create the database and the tables
    WidgetShop seed     insert sample customers and orders
    WidgetShop list     print every order, biggest first
    WidgetShop reset    drop the tables again
 
  The database it talks to is the one started by compose.yaml next to this file. }
 
{$APPTYPE CONSOLE}
 
uses
  System.SysUtils,
  FireDAC.Stan.Intf,
  FireDAC.Stan.Option,
  FireDAC.Stan.Error,
  FireDAC.Stan.Def,
  FireDAC.Stan.Pool,
  FireDAC.Stan.Async,
  FireDAC.Stan.Param,
  FireDAC.DatS,
  FireDAC.DApt,
  FireDAC.DApt.Intf,
  FireDAC.Phys,
  FireDAC.Phys.Intf,
  FireDAC.Phys.PG,
  FireDAC.Phys.PGDef,
  FireDAC.UI.Intf,
  FireDAC.ConsoleUI.Wait,
  FireDAC.Comp.Client,
  FireDAC.Comp.DataSet,
  App.Config in 'App.Config.pas',
  App.Database in 'App.Database.pas',
  App.Shop in 'App.Shop.pas';
 
const
  LineWidth = 71;
 
procedure PrintUsage;
begin
  Writeln('WidgetShop - a tiny PostgreSQL client written in Delphi');
  Writeln;
  Writeln('Usage: WidgetShop <command>');
  Writeln;
  Writeln('  setup   create the database and the tables');
  Writeln('  seed    insert sample customers and orders');
  Writeln('  list    print every order, biggest first');
  Writeln('  reset   drop the tables again');
end;
 
procedure RunSetup(const AConfig: TAppConfig);
var
  Conn: TFDConnection;
begin
  if EnsureDatabase(AConfig) then
    Writeln('Created database "', AConfig.Database, '".')
  else
    Writeln('Database "', AConfig.Database, '" already exists.');
 
  Conn := OpenAppConnection(AConfig);
  try
    CreateSchema(Conn);
    Writeln('Tables "customers" and "orders" are ready.');
  finally
    Conn.Free;
  end;
end;
 
procedure RunSeed(const AConfig: TAppConfig);
var
  Conn: TFDConnection;
begin
  Conn := OpenAppConnection(AConfig);
  try
    Writeln(Format('Inserted %d orders for 2 customers.',
      [SeedSampleData(Conn)]));
  finally
    Conn.Free;
  end;
end;
 
procedure RunList(const AConfig: TAppConfig);
var
  Conn: TFDConnection;
  Lines: TArray<TOrderLine>;
  Line: TOrderLine;
  Total: Currency;
begin
  Conn := OpenAppConnection(AConfig);
  try
    Lines := FetchOrderLines(Conn);
  finally
    Conn.Free;
  end;
 
  if Length(Lines) = 0 then
  begin
    Writeln('No orders yet - run "WidgetShop seed" first.');
    Exit;
  end;
 
  Writeln(Format('%-4s %-14s %-24s %4s %10s %10s',
    ['#', 'CUSTOMER', 'PRODUCT', 'QTY', 'PRICE', 'TOTAL']));
  Writeln(StringOfChar('-', LineWidth));
 
  Total := 0;
  for Line in Lines do
  begin
    Writeln(Format('%-4d %-14s %-24s %4d %10.2f %10.2f',
      [Line.OrderId, Line.Customer, Line.Product,
       Line.Quantity, Line.UnitPrice, Line.LineTotal]));
    Total := Total + Line.LineTotal;
  end;
 
  Writeln(StringOfChar('-', LineWidth));
  Writeln(Format('%-60s %10.2f', ['TOTAL ORDER VALUE', Total]));
end;
 
procedure RunReset(const AConfig: TAppConfig);
var
  Conn: TFDConnection;
begin
  Conn := OpenAppConnection(AConfig);
  try
    DropSchema(Conn);
    Writeln('Tables dropped. The database itself is still there.');
  finally
    Conn.Free;
  end;
end;
 
var
  Config: TAppConfig;
  Command: string;
begin
  try
    Config := TAppConfig.Load;
 
    if ParamCount = 0 then
      PrintUsage
    else
    begin
      Command := LowerCase(ParamStr(1));
      if Command = 'setup' then
        RunSetup(Config)
      else if Command = 'seed' then
        RunSeed(Config)
      else if Command = 'list' then
        RunList(Config)
      else if Command = 'reset' then
        RunReset(Config)
      else
      begin
        Writeln(ErrOutput, 'Unknown command: ', Command);
        Writeln;
        PrintUsage;
        ExitCode := 2;
      end;
    end;
  except
    on E: Exception do
    begin
      // A CLI reports failure on stderr and with a non-zero exit code, so a
      // build script or a scheduled task can tell that something went wrong.
      Writeln(ErrOutput, E.ClassName, ': ', E.Message);
      ExitCode := 1;
    end;
  end;
end.

Diese uses-Klausel ist lang, und sie ist aus einem Grund lang, den man kennen sollte. Wenn Sie Komponenten auf ein Formular ziehen, fügt die IDE diese Units still für Sie hinzu; in einem handgeschriebenen Konsolenprogramm fügen Sie sie selbst hinzu. Zwei davon vergisst man besonders gern. FireDAC.Phys.PG und FireDAC.Phys.PGDef sind es, die den PostgreSQL-Treiber überhaupt registrieren — ohne sie scheitert DriverID := 'PG' zur Laufzeit mit einem „Treiber nicht gefunden", obwohl alles kompiliert. Und FireDAC.ConsoleUI.Wait ist der konsolentaugliche Ersatz für die dialogbasierte Warte-UI, die eine VCL-Anwendung verwenden würde; lassen Sie sie weg, beschwert sich FireDAC, dass ihm eine UI fehlt.

Der letzte Block ist klein, aber er ist es, der ein Kommandozeilenwerkzeug von einem Programm unterscheidet, das zufällig etwas ausgibt. Fehler gehen an ErrOutput statt an die Standardausgabe, damit ein Aufrufer den Bericht von der Beschwerde trennen kann. ExitCode ist 1 bei einem Fehlschlag und 2 bei einem unbekannten Befehl, sodass eine Batchdatei, eine geplante Aufgabe oder ein CI-Job auf das Ergebnis verzweigen kann, statt Text zu durchsuchen.

Eine Meinung: Lassen Sie die Anwendung ihre Datenbank selbst anlegen

Hier ist eine Entscheidung, die ich bewusst getroffen habe, und ich sage offen dazu, dass sie eine Vorliebe ist und keine Tatsache. Die Compose-Datei hätte die Datenbank für uns anlegen können — das offizielle Image liest eine Variable POSTGRES_DB, die "can be used to define a different name for the default database that is created when the image is first started." Eine Zeile, und widgetshop hätte existiert, bevor Delphi je gelaufen wäre. Ich habe das nicht getan, und für ein Programm wie dieses halte ich die Variante oben für besser.

Meine Begründung: Ein Werkzeug, das sich selbst hochziehen kann, ist ein Werkzeug, das Sie jemand anderem in die Hand drücken können. WidgetShop setup funktioniert gegen einen Container, gegen den Server eines Kollegen, gegen eine frische Cloud-Instanz — überall dort, wo die Zugangsdaten es hineinlassen. In dem Moment, in dem die Existenz der Datenbank zu einer Eigenschaft Ihrer Compose-Datei wird, läuft die Anwendung nur noch in der einen Umgebung, die diese Compose-Datei beschreibt, und „bei mir läuft es" hat ein neues Versteck.

Dieses Argument hat allerdings echte Grenzen, und sie zu verschweigen wäre Ihnen gegenüber unfair.

Nutzen Sie das Muster also dort, wo es passt — und wissen Sie genau, wann Sie es aufgeben. Diese Grenze ist der nützliche Teil.

Schritt 4 — Bauen

Sie können WidgetShop.dproj in der IDE öffnen und wie jedes andere Projekt F9 drücken, das ist der kürzeste Weg. Aber der Bau aus dem Terminal lohnt sich ebenfalls, denn genau das tut ein Build-Server, und er macht aus „kompiliert das noch?" eine Fünf-Sekunden-Frage.

Aus dem Terminal bauen

RAD Studio liefert eine Batchdatei mit, die Compiler und Suchpfade in Ihre Umgebung legt, und danach erledigt MSBuild die Arbeit. Führen Sie diese zwei Zeilen im Projektordner in cmd.exe aus:

call "C:\Program Files (x86)\Embarcadero\Studio\37.0\bin\rsvars.bat"
msbuild WidgetShop.dproj /t:Build /p:Config=Release /p:Platform=Win64

Passen Sie 37.0 an Ihre RAD-Studio-Version an und tauschen Sie Win64 gegen Win32, wenn das Ihr Ziel ist — denken Sie nur daran, dass die Wahl zur Bitness der libpq.dll in Ihrem PATH passen muss. Die ausführbare Datei landet in .\Win64\Release\WidgetShop.exe.

Die Projektdatei selbst ist ganz gewöhnliches MSBuild-XML. Der Teil, den Sie von Hand bearbeiten würden, ist die Liste der Quelldateien — dort werden die drei Units hereingeholt:

<ItemGroup>
    <DelphiCompile Include="$(MainSource)">
        <MainSource>MainSource</MainSource>
    </DelphiCompile>
    <DCCReference Include="App.Config.pas"/>
    <DCCReference Include="App.Database.pas"/>
    <DCCReference Include="App.Shop.pas"/>
    <BuildConfiguration Include="Base">
        <Key>Base</Key>
    </BuildConfiguration>
    <BuildConfiguration Include="Debug">
        <Key>Cfg_2</Key>
        <CfgParent>Base</CfgParent>
    </BuildConfiguration>
    <BuildConfiguration Include="Release">
        <Key>Cfg_1</Key>
        <CfgParent>Base</CfgParent>
    </BuildConfiguration>
</ItemGroup>

Die vollständige .dproj — Plattformen Win32 und Win64, Konfigurationen Debug und Release — liegt neben den Quelldateien, statt hier noch einmal abgedruckt zu werden, denn hundert Zeilen Build-XML würden die Teile dieses Beitrags ertränken, die tatsächlich etwas beibringen.

Schritt 5 — Ausführen

Mit gesundem Container und gebauter Anwendung laufen die drei Befehle in der erwarteten Reihenfolge. Beginnen Sie mit setup.

WidgetShop.exe setup
Created database "widgetshop".
Tables "customers" and "orders" are ready.

Führen Sie ihn ein zweites Mal aus, ändert sich die erste Zeile zu Database "widgetshop" already exists. — das ist DatabaseExists bei der Arbeit, und es macht den Befehl sicher genug für ein Skript. Als Nächstes ein paar Zeilen hinein.

WidgetShop.exe seed
Inserted 4 orders for 2 customers.

Und schließlich wieder auslesen, verbunden mit ihren Kunden und nach Wert sortiert.

WidgetShop.exe list
#    CUSTOMER       PRODUCT                   QTY      PRICE      TOTAL
-----------------------------------------------------------------------
1    Ada Lovelace   Analytical Engine Gear      4     129.50     518.00
4    Grace Hopper   COBOL Manual                2      42.00      84.00
2    Ada Lovelace   Punch Card Set             12       3.75      45.00
3    Grace Hopper   Nanosecond Wire             1      11.80      11.80
-----------------------------------------------------------------------
TOTAL ORDER VALUE                                                658.80

Beachten Sie die Reihenfolge: Zeile 4 steht über Zeile 2, weil nach Zeilensumme sortiert wird und nicht nach Id. PostgreSQL hat quantity * unit_price berechnet und danach sortiert, und Delphi hat schlicht ausgegeben, was zurückkam. Ein kleiner Hinweis zu dieser Ausgabe: Format verwendet bei %.2f das Dezimaltrennzeichen aus den Regionseinstellungen des Rechners, auf einem deutschen oder französischen Windows erscheinen diese Zahlen also mit Komma. Wenn die Ausgabe überall zeichengleich sein muss, weil etwas nachgelagert sie auswertet, übergeben Sie Format ein explizites TFormatSettings, statt es die Locale lesen zu lassen.

Zum Schluss ist der Abbau symmetrisch. WidgetShop.exe reset löscht die Tabellen, behält aber die Datenbank; docker compose down stoppt den Server, behält aber das Volume; und docker compose down -v löscht auch das Volume und damit jede Zeile.

Wenn es schiefgeht

Vier Fehler machen fast jeden misslungenen ersten Versuch aus, und alle vier sind schnell erledigt, sobald man sie erkennt. Das steckt jeweils wirklich dahinter.

Was Sie sehenWas es tatsächlich bedeutet
Der PG-Treiber lässt sich nicht laden, oder libpq.dll wird nicht gefundenlibpq.dll fehlt im PATH, oder ihre Bitness passt nicht zu Ihrem Build
Verbindung auf localhost:5432 abgelehntDer Container läuft nicht oder ist noch nicht gesund — docker compose ps prüfen
Passwortauthentifizierung für Benutzer postgres fehlgeschlagenDas Passwort in App.Config passt nicht zu POSTGRES_PASSWORD in compose.yaml
CREATE DATABASE cannot run inside a transaction blockDie Anweisung ging über eine Verbindung mit offener Transaktion hinaus

Der zweite Fall hat eine besonders häufige Spielart: Der Container läuft, aber die Datenbank startet noch, also wird die Verbindung ein, zwei Sekunden lang abgelehnt. Genau davon soll Ihnen der Healthcheck in der Compose-Datei erzählen — warten Sie auf (healthy), nicht bloß auf Up.

Dieselbe Datenbank empfängt auch andere Werkzeuge

Nichts von dem, was der Server hier tut, ist Delphi-spezifisch, und das ist eine echte Stärke und kein Vorbehalt. Es ist ein normaler PostgreSQL-Server auf einem normalen Port, also ist alles willkommen, was das Protokoll spricht — was zugleich bedeutet, dass Sie nirgends festsitzen.

FireDAC verdient seinen Platz, weil es direkt mitgeliefert wird, mit starker PostgreSQL-Unterstützung und einem Komponentenmodell, das die meisten Delphi-Entwickler im Schlaf beherrschen. Die quelloffenen Alternativen verdienen ihren Platz über die Lizenz und darüber, dass sie Free Pascal erreichen. Was passt, hängt davon ab, was Sie bauen — nicht davon, was besser ist.

Fazit

Sie haben jetzt eine Datenbankanwendung statt eines Datenbank-Schnipsels: einen Server, der in einer eincheckbaren Datei beschrieben ist, eine Anwendung, die ihr eigenes Schema anlegt, parametrisiertes SQL, eine Transaktion, die sauber zurückrollt, einen Join, der dort berechnet wird, wo Joins hingehören, und einen Build, den Sie aus dem Terminal starten können, ohne die Maus anzufassen.

Ein PostgreSQL-Server ist ein docker compose up -d entfernt, und ein vollständiger Delphi-Client sind vier kleine Dateien — solange libpq.dll auf dem Host liegt und zur Bitness Ihres Builds passt.

Drei Dinge lohnen sich zum Mitnehmen. Erstens: Die Portzuordnung und die Client-Bibliothek sind die einzigen zwei Installationsteile, die je wirklich Ärger machen; alles darüber ist gewöhnliches FireDAC. Zweitens: Eine Unit für die Einstellungen, eine für das Schema, eine für die Daten und eine für den Menschen hält SQL von der ersten Zeile an aus Ihrem UI-Code heraus — was heute nichts kostet und Ihnen später eine Neufassung spart. Drittens: Selbst-Bootstrapping ist eine Entwicklungsbequemlichkeit und keine Produktionsarchitektur — wissen Sie, wann diese Aufgabe an einen Administrator und ein Migrationsskript gehört.

Jede Unit oben kompiliert sauber mit dem Kommandozeilencompiler von RAD Studio 13, sowohl für Win32 als auch für Win64, und das vollständige Projekt — Quellen, .dproj und compose.yaml — liegt im Ordner WidgetShop neben diesem Beitrag, bereit zum Bauen.