mitmario.dev

Suche und Abschluss

PHP Sandbox 5 Min Lesezeit 3 BeispieleLektion 6 von 7

Vier Lesezeichen sind übersichtlich. Vierzig sind es nicht mehr, und spätestens dann will jemand tippen können, statt zu scrollen. Die letzte Funktion des Projekts ist deshalb eine Suche, und sie besteht aus einer einzigen Abfrage.

Eine Suche ist ein LIKE

Suchen mit und ohne Platzhalter
<?php

declare(strict_types=1);

// Eine Suche ist eine Abfrage mit LIKE. Und sie ist die Stelle, an der ein
// zusammengeklebtes SQL am schnellsten auffliegt: Der Suchbegriff kommt
// wortwoertlich vom Besucher.

$db = new PDO("sqlite::memory:", null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$db->exec("CREATE TABLE lesezeichen (id INTEGER PRIMARY KEY, titel TEXT NOT NULL)");
$db->exec("INSERT INTO lesezeichen (titel) VALUES
    ('Astro'), ('PHP-Handbuch'), ('O''Reilly'), ('Wikipedia')");

function geklebt(PDO $db, string $begriff): void
{
    try {
        $treffer = $db->query("SELECT titel FROM lesezeichen WHERE titel LIKE '%$begriff%'")->fetchAll();

        echo "  geklebt:      ", count($treffer), " Treffer\n";
    } catch (PDOException $e) {
        echo "  geklebt:      ", $e->getMessage(), "\n";
    }
}

function vorbereitet(PDO $db, string $begriff): void
{
    $abfrage = $db->prepare("SELECT titel FROM lesezeichen WHERE titel LIKE :s");
    $abfrage->execute([":s" => "%" . $begriff . "%"]);
    $treffer = $abfrage->fetchAll();

    echo "  vorbereitet:  ", count($treffer), " Treffer",
        $treffer === [] ? "" : " (" . implode(", ", array_column($treffer, "titel")) . ")", "\n";
}

foreach (["php", "PHP", "o'reilly", "xyz"] as $begriff) {
    echo "Gesucht wird nach \"", $begriff, "\":\n";
    geklebt($db, $begriff);
    vorbereitet($db, $begriff);
    echo "\n";
}

WHERE titel = 'php' findet nur, was genau so heißt. Für eine Suche will man das Gegenteil: Was enthält diesen Teil? Dafür gibt es LIKE und das Prozentzeichen, das für beliebig viele Zeichen steht. LIKE '%php%' findet PHP-Handbuch, und nebenbei ist es bei SQLite unempfindlich gegen Groß- und Kleinschreibung, jedenfalls für die Buchstaben ohne Umlaut.

Das erste Beispiel stellt zwei Fassungen derselben Suche nebeneinander. Die eine klebt den Suchbegriff in die Abfrage, die andere gibt ihn als Wert mit. Bei php liefern beide dasselbe. Bei o'reilly liefert die geklebte Fassung near "reilly": syntax error, weil der Apostroph die Zeichenkette im SQL beendet.

Das ist Lektion 14.4, und die Suche ist die Stelle, an der es am schnellsten passiert. Bei einem Formularfeld denkt man an Eingaben. Ein Suchfeld fühlt sich harmloser an, dabei ist es das Feld, in das die Leute am ehesten Sonderzeichen tippen.

Prozent und Unterstrich

Prozent und Unterstrich
<?php

declare(strict_types=1);

// In einem LIKE-Muster bedeuten zwei Zeichen etwas: % steht fuer beliebig
// viele Zeichen, _ fuer genau eines. Wer sie eintippt, meint sie aber als
// Zeichen. Ein Platzhalter schuetzt davor nicht: Er schuetzt vor fremdem
// SQL, nicht vor fremden Mustern.

$db = new PDO("sqlite::memory:", null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$db->exec("CREATE TABLE lesezeichen (id INTEGER PRIMARY KEY, titel TEXT NOT NULL)");
$db->exec("INSERT INTO lesezeichen (titel) VALUES
    ('Astro'), ('PHP-Handbuch'), ('Wikipedia'), ('50 % schneller')");

function fuerLike(string $begriff): string
{
    // Der Rueckstrich zuerst, sonst verdoppelt der naechste Schritt genau
    // die Rueckstriche, die dieser hier erst erzeugt hat.
    return str_replace(["\\", "%", "_"], ["\\\\", "\\%", "\\_"], $begriff);
}

function suche(PDO $db, string $muster, bool $maskiert): array
{
    $sql = $maskiert
        ? "SELECT titel FROM lesezeichen WHERE titel LIKE :s ESCAPE '\\'"
        : "SELECT titel FROM lesezeichen WHERE titel LIKE :s";

    $abfrage = $db->prepare($sql);
    $abfrage->execute([":s" => "%" . ($maskiert ? fuerLike($muster) : $muster) . "%"]);

    return array_column($abfrage->fetchAll(), "titel");
}

echo str_pad("Eingetippt", 14), str_pad("roh durchgereicht", 20), "maskiert\n";
echo str_repeat("-", 60), "\n";

foreach (["php", "%", "_", "50 %", "\\"] as $begriff) {
    $roh = suche($db, $begriff, false);
    $sicher = suche($db, $begriff, true);

    echo str_pad('"' . $begriff . '"', 14),
        str_pad(count($roh) . " von 4", 20),
        count($sicher), " von 4",
        $sicher === [] ? "" : ": " . implode(", ", $sicher), "\n";
}

echo "\nOhne Maskierung sind Prozent und Unterstrich also keine Suche mehr,\n",
    "sondern ein Muster, das alles trifft. Und der letzte Fall zeigt, warum\n",
    "der Rueckstrich selbst mitmaskiert werden muss: Sonst waere er ein\n",
    "halbes Escape-Zeichen ohne Zeichen dahinter.\n";

Und dann gibt es eine zweite Falle, gegen die ein Platzhalter nichts ausrichtet. Ein Platzhalter sorgt dafür, dass der Wert ein Wert bleibt und kein SQL wird. Er sorgt nicht dafür, dass der Wert kein Muster wird, denn im Muster steht er ja.

Zwei Zeichen bedeuten in einem LIKE etwas: % steht für beliebig viele Zeichen, _ für genau eines. Wer eines davon eintippt, meint das Zeichen. Sieh dir im zweiten Beispiel die mittlere Spalte an: Ein einzelner Unterstrich findet alle vier Einträge, ein einzelnes Prozentzeichen ebenfalls. Aus der Suche ist eine Anzeige aller Datensätze geworden.

Der Ausweg heißt ESCAPE. Man setzt vor jedes besondere Zeichen ein Escape-Zeichen, sagt der Abfrage mit ESCAPE '\', welches das ist, und schon ist ein Prozentzeichen wieder ein Prozentzeichen. Das Escape-Zeichen selbst muss dabei mitmaskiert werden, sonst kann man es nicht mehr eintippen. Deshalb steht der Rückstrich in der Ersetzung an erster Stelle: Käme er zuletzt, würde er genau die Rückstriche verdoppeln, die die Ersetzung vorher erst erzeugt hat.

Schlimm ist die Falle hier nicht, sie liefert nur zu viele Treffer. In einer Anwendung mit vielen Zeilen ist sie trotzdem unangenehm: Eine Suche nach % liest die ganze Tabelle, und das kann jeder auslösen, der das Suchfeld findet.

Der Rundgang

Das Projekt ist damit fertig. Und weil in seinen zehn Dateien fast der ganze Kurs steckt, lohnt sich ein Blick darauf, woher welche Zeile stammt.

oeffentlich/index.php ist Abschnitt 16 in Reinform: der Front Controller aus 16.3, die Routenschleife aus 16.4, die Konfiguration aus 16.5, die Vorlagenfunktion aus 16.2. Und das Verzeichnis selbst ist die Antwort auf 15.6: Es ist das einzige, das der Server kennt.

src/LesezeichenSpeicher.php ist Abschnitt 14, mit der Klasse aus 13.1 und dem Namensraum aus 13.5. Jede Abfrage darin geht über prepare, und das ist 14.4 und 15.1 in einer Zeile. Dass sie ihren Dateipfad entgegennimmt, statt ihn zu kennen, ist 17.4.

src/seiten.php ist der Rest: die Weiterleitung nach dem Absenden aus 11.3, die Meldung in der Sitzung aus 12.4, die Anmeldung aus 12.5, das Passwort-Hashing aus 15.4, das Token aus 15.3 und die Eingabeprüfung aus 11.2.

ansichten/ ist Lektion 11.4 und 15.2: Um jeden fremden Wert liegt ein e(), und zwar beim Ausgeben und nicht beim Speichern.

Und src/funktionen.php ist die Sammelstelle für alles, was sonst zehnmal dastünde. Acht kleine Funktionen, die längste hat acht Zeilen.

Was du testen würdest

Die Testsuite des Projekts
<?php

declare(strict_types=1);

use App\LesezeichenSpeicher;
use PHPUnit\Framework\TestCase;

final class LesezeichenSpeicherTest extends TestCase
{
    private LesezeichenSpeicher $speicher;

    // Vor jedem Test eine frische Datenbank, und zwar im Arbeitsspeicher.
    // Genau deshalb nimmt der Konstruktor den Pfad entgegen, statt ihn zu
    // kennen: Was hereingegeben wird, kann ein Test bestimmen. Das ist die
    // Regel aus Lektion 17.4, hier an der eigenen Klasse.
    protected function setUp(): void
    {
        $this->speicher = new LesezeichenSpeicher(":memory:");
        $this->speicher->benutzerAnlegen("mia", "egal");
        $this->speicher->benutzerAnlegen("tom", "egal");
    }

    public function testJederSiehtNurSeineEigenen(): void
    {
        $this->speicher->anlegen(1, "Astro", "https://astro.build");
        $this->speicher->anlegen(2, "Toms Werkstatt", "https://example.org/tom");

        $this->assertCount(1, $this->speicher->alleVon(1));
        $this->assertSame("Astro", $this->speicher->alleVon(1)[0]["titel"]);
    }

    public function testDasNeuesteStehtOben(): void
    {
        $this->speicher->anlegen(1, "Zuerst", "https://example.org/1");
        $this->speicher->anlegen(1, "Danach", "https://example.org/2");

        $this->assertSame("Danach", $this->speicher->alleVon(1)[0]["titel"]);
    }

    public function testDieSucheFindetEinenTeilDesTitels(): void
    {
        $this->speicher->anlegen(1, "PHP-Handbuch", "https://www.php.net/manual/de/");

        $this->assertCount(1, $this->speicher->suchen(1, "handbuch"));
        $this->assertCount(0, $this->speicher->suchen(1, "javascript"));
    }

    public function testEinProzentzeichenIstEinZeichenUndKeinMuster(): void
    {
        $this->speicher->anlegen(1, "PHP-Handbuch", "https://www.php.net/manual/de/");

        $this->assertCount(0, $this->speicher->suchen(1, "%"));
    }

    public function testGeloeschtIstGeloescht(): void
    {
        $this->speicher->anlegen(1, "Weg damit", "https://example.org/weg");
        $id = $this->speicher->alleVon(1)[0]["id"];

        $this->speicher->loeschen((int) $id);

        $this->assertSame([], $this->speicher->alleVon(1));
        $this->assertFalse($this->speicher->holen((int) $id));
    }
}

Abschnitt 17 hat gefragt, wo Tests am meisten bringen. Am Projekt lässt sich die Antwort ablesen: an der Speicherklasse. Sie rechnet, sie gibt nichts aus, sie nimmt ihre Zutaten entgegen, und sie enthält genau die Regeln, bei denen ein Fehler still bliebe.

Das dritte Beispiel ist eine kleine Testsuite dafür. Fünf Tests, und jeder prüft eine Regel, die man sonst nur beim Durchklicken bemerkt: dass jeder nur seine eigenen sieht, dass das Neueste oben steht, dass die Suche einen Teil des Titels findet, dass ein Prozentzeichen ein Zeichen bleibt und dass Gelöschtes weg ist. Im Terminal startest du sie wie in Abschnitt 17 mit vendor/bin/phpunit, und vendor/bin/phpunit --testdox liest die fünf Namen als Sätze vor.

Der interessante Teil steht in setUp(). Die Klasse bekommt dort :memory: als Datenbankpfad, also eine Datenbank, die nur im Arbeitsspeicher existiert und nach jedem Test wieder weg ist. Möglich ist das nur, weil der Konstruktor den Pfad entgegennimmt. Eine Klasse, die sich ihre Verbindung selbst baut, könnte man so nicht prüfen, und genau das ist die Aussage von Lektion 17.4.

Zwei Dinge fehlen der Suite mit Absicht. Die Startdaten der Lektion sind ausgebaut, denn ein Test legt sich seinen Zustand selbst hin. Und die Seitenfunktionen sind nicht dabei: Sie lesen $_SESSION und $_POST, schreiben Kopfzeilen und beenden das Programm. Testbar wären sie erst, wenn Anfrage und Antwort Werte wären, die man hereingibt. Genau das tun Frameworks, und es ist einer der Gründe dafür.

Was jetzt kommt

Du hast eine Anwendung gebaut, die tut, was Anwendungen tun. Was fehlt, ist nichts Geheimnisvolles, sondern Menge: mehr Seiten, mehr Regeln, mehr Leute. Und ab einer bestimmten Menge lohnen sich drei Dinge, die du jetzt einordnen kannst.

Ein Framework. Laravel oder Symfony bringen genau die Teile mit, die du hier selbst geschrieben hast: Router, Vorlagen, Konfiguration, Sitzung, Token, Datenbankzugriff. Der Unterschied ist, dass ihre Fassungen tausend Fälle mehr kennen als deine. Wer sie ohne diesen Abschnitt anfasst, hält sie für Magie. Wer die Bausteine selbst gebaut hat, erkennt sie wieder, und das ist der ganze Grund, warum dieser Kurs keines benutzt hat.

Mehr SQL. Indizes, damit die Suche bei vierzigtausend Zeilen noch schnell ist. Migrationen, damit eine neue Spalte kein Handgriff auf dem Server ist. Und irgendwann MySQL oder PostgreSQL statt SQLite, was aus Sicht deines Codes eine geänderte Zeile im Konstruktor ist.

Deployment. Wie die Anwendung auf einen Server kommt, wie sie dort ihre Zugangsdaten bekommt, wie Sicherungen laufen und wie HTTPS dazukommt. Das ist ein eigener Kurs, und ohne ihn bleibt jedes Projekt auf dem eigenen Rechner.

Was dieser Kurs bewusst nicht beantwortet hat, steht damit auch fest: E-Mail, Dateiuploads über das eine Beispiel in 11.6 hinaus, mehrsprachige Oberflächen, Rechte über „gehört dir” hinaus, und alles, was mit mehreren gleichzeitigen Servern zu tun hat. Nichts davon ist schwer, sobald das hier sitzt.

Zum Mitnehmen

Die letzte Funktion des Projekts ist eine Abfrage mit LIKE, und sie hat eine Falle, die kein Platzhalter abfängt. Danach ein Rundgang durch das Fertige: welche Zeile aus welchem Abschnitt stammt, und was als Nächstes käme.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.