MS-SQL mit Symfony unter Linux

Das Problem

In manchen Fällen möchte man von seiner Symfony-Applikation, die auf einem Linux-Server läuft, auf einen Microsoft SQL-Server zugreifen. In meinem konkreten Anwendungsfall lief die eigentliche Symfony-Applikation auf einem aktuellen Debian mit Doctrine auf MariaDB und sollte aus einem anderen System zusätzliche Informationen holen. Dieses System besaß weder eine nutzbare API noch einfach auswertbare Webseiten/JSON-Antworten sodass nur der unschöne direkte Weg über die Datenbank ging.

Als Entwickler ist man meist naiv…

…denn man geht davon aus, dass ein so weit verbreiteter Server auch eine vernünftige Unterstützung hat. Grade jetzt, wo Microsoft in Windows 10/11 ein Linux-Subsystem einsetzt und sich Open Source ganz groß auf die Fahne schreibt. Erste Recherchen zeigten, dass Microsoft zu Zeiten von PHP 5.5 mal einen Datenbanktreiber erstellt hatte. Doctrine kann diesen auch mit dem Datenbanktypen mssql (alias pdo_sqlsrv) verwenden.

Sucht man nun den pdo_sqlsrv, landet man unter Microsoft SQL Server Functions (PDO_SQLSRV) auf php.net auf der Installstionsseite, die einen direkt ersteinmal aufklärt

The PDO_SQLSRV extension is only compatible with PHP running on Windows.

Leider wurder der Treiber nach PHP5.5 (Juni 2013) nur noch unter Windows weiterentwickelt. Darunter wird jedoch auf eine Alternative verwiesen. Den Microsoft ODBC-Treiber für Linux….zuletzt aktualisiert 2012. Man kann sich schon denken, wohin das führt, ein Blick in die README bestätigt dies aber:

This driver is only supported on 64-bit Red Hat Enterprise Linux 5 or 64-bit Red Hat Enterprise Linux 6. Other configurations are not supported and will not likely work.

OK, kein Support durch Microsoft….

Im Netz existieren noch einige Anleitungen, wie man mod_mssql Als letzte Möglichkeit kann noch der mod_mssql_so elbst compiliert werden.
https://www.activelamp.com/blog/development/mssql-symfony

Die Community kommt zur Hilfe

Wie es häufig bei proprietären Microsoft-Lösungen ist, findet die Linux-Community eine Lösung. In diesem Fall war Brian Bruns zur Jahrtausendwende mit FreeTDS.

Die Installation unter Debian gestaltet sich denkbar einfach:

sudo apt install freetds-common freetds-bin unixodbc tdsodbc
sudo apt install php-sybase    

Die zweite Zeile wird später benötigt, um von PHP auf FreeTDS zuzugreifen. Zuerst wird aber der ODBC-Treiber getestet. FreeTDS kann entweder per Konfigurationsdatei (/etc/freetds/freetds.conf Konfiguriert werden oder bei dem Aufruf mit allen notwendigen Parametern versorgt werden. Da die Datenbankverbindung später durch die Web-Applikation konfiguriert werden soll, wird die letztere Variante verwendet und man muss keine Anpassungen an der Konfigurationsdatei machen.

Testen kann man den ODBC-Treiber über das Kommandozeilentool tsql:

#tsql -H <hostname> -p <port> -U <username> -P <password> -D <database>
tsql -H sqlsrv.example.com -p 1443 -U test -P geheim -D testdatabase

Ein erfolgreiche Verbindung sieht etwa so aus:

    locale is "de_DE.UTF-8"
    locale charset is "UTF-8"
    using default charset "UTF-8"
    Setting lb as default database in login packet
    1> quit

Jetzt „nur“ noch in Doctrine einbinden

Damit dachte ich, wäre der Drops gelutscht und ich könnte zum interessanten Teil übergehen….doch weit gefehlt. Denn wie sage ich nun Doctrine, dass es die ODBC-Verbindung verwenden soll? Definiert man bei Doctrine die Platform sqlsrv, möchte Doctrine immer noch pdo_sqlsrv verwenden.

Im Netz geistern einige Composer-Module oder Symfony-Bundles herum, welche dieses Problem lösen woll(t)en. Beispielhaft seien hier genannt:

Kurzum: Sie lösen das Problem nicht mehr. Meist wird in den Bundles ein kleinerer oder größerer Teil von Doctrine geändert. Da jedoch in Doctrine einige Klassen private oder final sind/waren, mussten die Autoren der Packages den Weg gehen und legitime Doctrine-Klassen duplizieren. Diese Code-Duplizierung setzt jedoch vorraus, dass jegliche Änderungen in den Funktionssignaturen nachgepflegt werden, was bei allen von mir gefundenen Packages nicht der Fall ist. Ich habe bei einem der Pakete einmal angefangen, die Signaturen entsprechend anzupassen, bin aber schnell zum Punkt gekommen, wo die generelle Klassenstruktur hätte verändert werden müssen.

Ein neuer Treiber

Generell stellt sich sowieso die Frage, warum (fast) alles neu definiert werden muss, wenn doch nur das Transport-Layer geändert werden soll (wir diskutieren ja über PDO). Hier kommt man schnell zum Ergebnis, dass eigentlich nur die Art, wie die PDO-Connection aufgebaut wird, anders ist und alles andere eigentlich schon vorhanden ist. Also schnell einen neuen Driver geschrieben (bzw. aus Doctrine kopiert und an den relevanten Stellen geändert; Vererbung geht wegen final-Schlüsselwort nicht):

<?php
// File: src/Lib/OdbcMssql/Driver.php

namespace App\Lib\OdbcMssql;

use Doctrine\DBAL\Driver\AbstractSQLServerDriver;
use Doctrine\DBAL\Driver\AbstractSQLServerDriver\Exception\PortWithoutHost;
use Doctrine\DBAL\Driver\Exception;
use Doctrine\DBAL\Driver\PDO\Connection as PDOConnection;
use Doctrine\DBAL\Driver\PDO\Exception as PDOException;
use Doctrine\DBAL\Driver\PDO\SQLSrv\Connection;
use function is_int;
use PDO;
use function sprintf;

final class Driver extends AbstractSQLServerDriver
{
    /**
     * {@inheritdoc}
     *
     * @return Connection
     */
    public function connect(array $params)
    {
        $driverOptions = $dsnOptions = [];

        if (isset($params['driverOptions'])) {
            foreach ($params['driverOptions'] as $option => $value) {
                if (is_int($option)) {
                    $driverOptions[$option] = $value;
                } else {
                    $dsnOptions[$option] = $value;
                }
            }
        }

        if (!empty($params['persistent'])) {
            $driverOptions[PDO::ATTR_PERSISTENT] = true;
        }

        try {
            $pdo = new PDO(
                $this->constructDsn($params, $dsnOptions),
                $params['user'] ?? '',
                $params['password'] ?? '',
                $driverOptions
            );
        } catch (\PDOException $exception) {
            throw PDOException::new($exception);
        }

        return new Connection(new PDOConnection($pdo));
    }

    /**
     * Constructs the Sqlsrv PDO DSN.
     *
     * @param mixed[]  $params
     * @param string[] $connectionOptions
     *
     * @throws Exception
     */
    private function constructDsn(array $params, array $connectionOptions): string
    {
        $dsn = 'odbc:Driver=FreeTDS;TDS_Version=7.4';

        if (isset($params['host'])) {
            $dsn .= sprintf(';Server=%s', $params['host']);
        } elseif (isset($params['port'])) {
            throw PortWithoutHost::new();
        }
        if (isset($params['port'])) {
            $dsn .= sprintf(';Port=%s', $params['port']);
        }

        if (isset($params['dbname'])) {
            $connectionOptions['Database'] = $params['dbname'];
        }

        if (isset($params['MultipleActiveResultSets'])) {
            $connectionOptions['MultipleActiveResultSets'] = $params['MultipleActiveResultSets'] ? 'true' : 'false';
        }

        return $dsn.$this->getConnectionOptionsDsn($connectionOptions);
    }

    /**
     * Converts a connection options array to the DSN.
     *
     * @param string[] $connectionOptions
     */
    private function getConnectionOptionsDsn(array $connectionOptions): string
    {
        $connectionOptionsDsn = '';

        foreach ($connectionOptions as $paramName => $paramValue) {
            $connectionOptionsDsn .= sprintf(';%s=%s', $paramName, $paramValue);
        }

        return $connectionOptionsDsn;
    }
}

Alles andere bringt bereits die sqlsrv Implementierung von Doctrine mit.

Die neue Klasse wird nun wie folgt in der doctrine.yaml eingebunden:

doctrine:
    dbal:
        # IMPORTANT: this configuration requires FreeTDS installed!
        driver_class: App\Lib\OdbcMssql\Driver
        driver: pdo_mssql
        dbname:           "%env(resolve:MSSQL_DB)%"
        user:             "%env(resolve:MSSQL_USER)%"
        password:         "%env(resolve:MSSQL_PASS)%"
        host:             "%env(resolve:MSSQL_HOST)%"
        port: 1433
        options: 
          'client_charset': 'UTF-8'
        server_version: '2008'

So wird der eigene Driver für die Connection verwendet, der pdo_mssql aber für sämtliche anderen Operationen, beispielsweise das Erzeugen von SQL oder die Hydration der Datentypen. Das Feld server_version muss auf 2008 stehen, damit pdo_mssql weiss, welche Syntaxvariante verwendet wird. 2008 ist hier der kleinste gemeinsame Nenner. In der .env oder .env.local müssen noch die 4 Variablen mit den Datenbankeinstellungen erstellt werden.

Danach kann die Verbindung einmal getestet werden. Dies geht am einfachsten über die Konsole.

$ php bin/console  dbal:run-sql --connection infor "SELECT blah FROM blubb WHERE 0=1"

In ExceptionConverter.php line 39:
                                                                                                                                                                         
  An exception occurred while executing a query: SQLSTATE[42S02]: Base table or view not found: 208 [FreeTDS][SQL Server]Invalid object name 'blubb'. (SQLExecute[208]   
  at ./ext/pdo_odbc/odbc_stmt.c:259)                                                                                                                                     

Die Fehlermeldung bedeutet, dass die Verbindung zum Datenbankserver erfolgreich war und die gewählte Datenbank existiert. Da wie einen Phantasienamen abgefragt haben, ist ein „Invalid object name“ genau das, was wir erwarten. Eigenlich sind

Wie gehts weiter?

Eigentlich kann man nun ganz normal mit Doctrine arbeiten. Wenn man jedoch mit einer bestehenden Datenbank arbeiten möchte, muss man händisch die Entities erzeugen, da bei MS-SQL die Erzeugung aus der Datenbank eher mäßig funktioniert.

In einem zukünftigen Tutorial werde ich einmal beschreiben, wie man gleichzeitig auf eine MS-SQL und eine MariaDB Datenbank zugreifen kann und dabei immer den korrekten Entity-Manager mit der passenden Datenbankverbindung bekommt.