PHP Data Objects uniwersalnym sposobem na obsługę baz danych

Artykuł dodany: 02 listopada 2011. Ostatnia modyfikacja: 30 września 2012.

Stopień trudności (1 - dla początkujących, 5 - dla ekspertów): 2

Zaczynając przygodę z PHP, kiedy już postanowisz wykorzystać jeden z dostępnych na rynku systemów DBMS, staniesz przed problemem które właściwie rozszerzenie PHP użyć. Oprócz natywnych dostępnych dla praktycznie każdej bazy, istnieją również bardziej abstrakcyjne, w szczególności wprowadzone w wersji PHP 5.1 PDO. Niewątpliwą przewagą tego rozwiązania nad innymi, zewnętrznymi projektami w tym np. ADodb jest kod napisany bezpośrednio w języku C. Narzut dzięki temu jest niewielki w porównaniu do kodu parsowanego już przez PHP. Zapewne na początek zaczniesz korzystać albo z bazy MySQL, albo PostgreSQL jako że są one popularne, darmowe i ogólnie wdrożone u dostawców hostingu. Przeglądając artykuły w sieci natkniesz się z całą pewnością na dużą liczbę przykładów w których autor wykorzystuje konstrukcję

mysql_connect() or die()

a według mnie powinieneś w tym momencie wyrzucić ten poradnik do wirtualnego kosza. Kod sugeruje bowiem iż tekst został napisany dobrych kilka lat temu i nie odwzorowuje realiów współczesnych projektów. Najważniejsze zastrzeżenie jest takie, że rozszerzenie mysql_ nie jest już dłużej wspierane i zostanie usunięte w przyszłych wersjach PHP (odpowiedni komunikat wyświetla się na angielskiej wersji dokumentacji wraz z sugestią doboru odpowiedniego API). Nie oznacza to oczywiście że należy całkowicie zapomnieć o strukturalnych rozszerzeniach. Wiele projektów jeszcze na nim bazuje, zewnętrzne rozszerzenie Microsoftu dla SQL Server oferuje znacznie bogatszą gamę funkcji niż to dostępne w rozszerzeniach PHP. Rozszerzenia producentów są też łatwiejsze w zrozumieniu dla początkującego który jeszcze nie do końca rozumie programowanie obiektowe. Dlaczego zatem warto korzystać z PDO?

  1. Obiektowość. W pewnym momencie na pewno nasz kod stanie się bardziej zaawansowany i wtedy połączenie obiektowego sterownika z resztą strony będzie dużo prostsze. Istnieje wiele zewnętrznych projektów (np Zend_Db) które rozszerzają klasę bazową PDO dodając zupełnie nowe funkcje.
  2. Bezpieczeństwo. W podstawowych sterownikach musimy zadbać o “escape” danych. Powoduje to że albo przepuszczamy każde zapytanie przez dodatkową funkcję, albo też musimy napisać dodatkowy parser zapytań. W PDO istnieje rozwiązanie zwane “prepared statements” o czym za chwilę.
  3. Wyjątki. Dużo łatwiej pisać aplikacje gdy cały kod możemy objąć blokiem wyjątków. Zwiększa to spójność i odporność na błędy.
  4. Przenośność. Większość podstawowych zapytań można wykorzystać na rożnych silnikach tylko poprzez zmianę nazwy sterownika.

Mimo że w tym momencie bardziej popularna dla zastosowań webowych jest baza MySQL postanowiłem dla potrzeb artykułu wykorzystać PostgreSQL. Oferuje ona chociażby schematy, zupełnie naturalne dla innych niż MySQL silników. Oprócz tego podstawowe zapytania będą wyglądały tak samo.

Inicjacja połączenia

Połączenie z poszczególnymi sterownikami baz danych nawiązujemy podając ciąg DSN. Zawiera on informację o tym, z jakiej bazy danych korzystamy i po dwukropku, informacje dla niej specyficzne. Posługując się przykładami z dokumentacji PDO spójrzmy jaką postać mogą przyjąć:

# Ciąg DSN identyfikujący połączenie z określonym systemem bazodaniowym:
mysql:dbname=testdb;host=127.0.0.1                                        # MySQL
odbc:DSN=SAMPLE;UID=john;PWD=mypass                                       # ODBC
oci:dbname=//localhost:1521/mydb                                          # Oracle
sqlite:/opt/databases/mydb.sq3                                            # SQLite
pgsql:host=localhost;port=5432;dbname=testdb;user=bruce;password=mypass   # PostgreSQL

Ciąg ten przekazujemy bezpośrednio do konstruktora obiektu PDO:

PDO::__construct() ( string $dsn [, string $uzytkownik [, string $haslo [, array $opcje_sterownika ]]] )

Nazwę użytkownika i hasło możemy przekazać jako ciąg DSN albo parametr konstruktora. Dla naszej testowej bazy danych Postgres ciąg taki może mieć postać:

<?php
error_reporting(E_ALL);
ini_set('display_errors', 'on');

$dsn = 'pgsql:host=localhost;port=5432;dbname=testdb;';
$user = 'postgres';
$pass = 'tajnehasło';
$dbh = new PDO($dsn, $user, $pass);

Wykonaliśmy połączenie ze sterownikiem pgsql, host lokalny (albo 127.0.0.1), na domyślnym dla tej bazy porcie 5432. Jeżeli baza testdb nie została jeszcze utworzona sterownik zwróci nam błąd:

Fatal error: Uncaught exception 'PDOException' with message 'SQLSTATE[08006] [7] KATASTROFALNY: baza danych "testdb" nie istnieje'

Wyjątki zwracane są przez klasę PDOException która z kolei rozszerza RuntimeException. Możemy przepisać nasz kod tak, aby obsłużyć rzucony wyjątek.

try {
  $dbh = new PDO($dsn, $user, $pass);
  echo 'Połączenie nawiązane.';
} catch(PDOException $e) {
  echo 'Klasa PDO zwróciła wyjątek: '.$e->getMessage();
}

Bardzo przydatne do obsługi błędów są trzy predefiniowane stałe:

  • PDO::ERRMODE_SILENT – domyślne. Sterownik zwraca kod błędu który możemy przechwycić metodami PDO::errorCode(), PDO::errorInfo().
  • PDO::ERRMODE_WARNING – jak wyżej ale dodatkowo zwraca E_WARNING. Przydatne jeżeli chcemy przechwycić błąd ale bez przerywania normalnego wykonywania aplikacji.
  • PDO::ERRMODE_EXCEPTION – bieg aplikacji jest przerywany i sterownik zwraca PDOException ze wszystkimi tego następstwami (cofnięcie wszystkich rozpoczętych transakcji)

Stałe możemy ustawić jako czwarty argument konstruktora lub przez wykonanie

$dbh->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );

Domyślnym schematem dla bazy PostgreSQL jest public do którego dostęp początkowo ma każdy. Dla naszej aplikacji chcielibyśmy jednak utworzyć nowy, testowy schemat dane a public wykasować całkowicie. Jak zatem ustawić nowo powstały schemat jako domyślny? Wykonując zwykłe zapytanie do bazy danych.

CREATE SCHEMA dane AUTHORIZATION postgres;
$dbh->exec('SET search_path TO "dane";');

Warto również pamiętać od razu o ustawieniu kodowania znaków dla naszego połączenia aby nie mieć problemów ze znakami narodowymi. Polecam oczywiście zestaw UTF-8 jako najbardziej uniwersalny.

$dbh->exec("SET NAMES 'UTF8';");

Metoda exec() wykonuje zapytanie SQL zwracając ilość dotkniętych zapytaniem rzędów.

Wykonywanie zapytań SQL

Wśród zalet rozszerzenia PDO wymieniłem dużą przenośność. Po powyższych przykładach widać, że aby wykonać połączenie do innej bazy należy wymienić ciąg DSN. Jednak w praktyce przenośność ta zależy od stopnia złożoności projektu DB. Od tego czy wykorzystujemy widoki i procedury do których dostęp w różnych DBMS tworzymy innymi zapytaniami. Sterownik PDO pozwala w pewnym stopniu pisać różny kod zależny od identyfikacji produktu.

phpinfo();
PDO Driver for PostgreSQL    enabled
PostgreSQL(libpq) Version    9.0.3

Pobierzmy dane identyfikacji połączenia:

echo 'Użyty sterownik: '.$dbh->getAttribute(PDO::ATTR_DRIVER_NAME);
echo '<br>Wersja sterownika: '.$dbh->getAttribute(PDO::ATTR_CLIENT_VERSION);
echo '<br>Wersja serwera: '.$dbh->getAttribute(PDO::ATTR_SERVER_VERSION);
Użyty sterownik: pgsql
Wersja sterownika: 9.0.3
Wersja serwera: 9.1.1

SET search_path to konstrukcja specyficzna dla pgsql możemy to zatem uwzględnić:

try {
  $dbh = new PDO($dsn, $user, $pass);
  $dbh->exec("SET NAMES 'UTF8';");
  if ('pgsql' == $dbh->getAttribute(PDO::ATTR_DRIVER_NAME)) {
    $dbh->exec('SET search_path TO "dane";');
    echo 'Domyślny schemat dla PostgreSQL ustawiony.';
  }
} catch(PDOException $e) {
  echo 'Klasa PDO zwróciła wyjątek: '.$e->getMessage();
}

Nie jest to rozwiązanie idealne, najlepiej gdyby składnia zapytań była identyczna co jednak w realnym świecie nie jest wykonalne.

Pora przejść do obsługi typowych zapytań SELECT, INSERT, UPDATE, DELETE. Dla naszych potrzeb stwórzmy prostą tabelę użytkowników z danymi:

CREATE TABLE dane.uzytkownicy
(
  id serial NOT NULL,
  imie character varying(50) NOT NULL,
  nazwisko character varying(200) NOT NULL,
  wiek integer,
  CONSTRAINT uzytkownicy_pkey PRIMARY KEY (id),
  CONSTRAINT uzytkownicy_wiek_check CHECK (wiek > 0 AND wiek < 140)
);

Najprostszym sposobem wstawienia rekordu jest wykonanie instrukcji

$ok = $dbh->exec("INSERT INTO uzytkownicy VALUES(DEFAULT, 'Jan', 'Kowalski', 20)");
if ($ok > 0) echo 'Rekord wstawiony prawidłowo';

Metoda PDO::exec() wykonuje bezpośrednio na bazie danych zapytania modyfikujące. Zwraca ilość wierszy. Zapoznaj się dodatkowo z dokumentacją i ostrzeżeniem w niej zawartym. Dla ułatwienia warto od teraz ustawić atrybut PDO::ERRMODE_EXCEPTION.

Sprawdźmy przy okazji ograniczenie uzytkownicy_wiek_check.

$dbh->exec("INSERT INTO uzytkownicy VALUES(DEFAULT, 'Jan', 'Kowalski', 0)");
Klasa PDO zwróciła wyjątek: SQLSTATE[23514]: Check violation: 7 BŁĄD: nowy rekord dla relacji "uzytkownicy" narusza ograniczenie sprawdzające "uzytkownicy_wiek_check"

Wszystko funkcjonuje prawidłowo jednak to my podaliśmy dane. A gdyby na stronie znajdował się formularz gdzie to użytkownik sam uzupełniłby swój krótki życiorys i dane te były by zmiennymi? Wykonanie bezpośredniego zapytania to prosta droga do ataku SQL Injection.

Prepared Statements

PDO umożliwia wcześniejsze przygotowanie zapytań w celu ich późniejszego wykonania. Jest to tak zwana metoda prepared statements czyli po polsku przygotowane wyrażania. Raz przygotowane wyrażenie można wykonywać wielokrotnie ale co ważniejsze, zapytanie takie można poprawnie zabezpieczyć. Zamiast podawać bezpośrednio wartość, zapytanie może zawierać znaki zapytania albo nazwy poprzedzone dwukropkiem (:imie). Wszystkie przygotowane w ten sposób wartości są odpowiednio dla danego adaptera “escape’owane” czyli poprzedzone znakami ucieczki. Nasze wcześniejsze zapytanie możemy zapisać w nowy sposób:

$sql = "INSERT INTO uzytkownicy VALUES(DEFAULT, :imie, :nazwisko, :wiek)";

albo po prostu

$sql = "INSERT INTO uzytkownicy VALUES(DEFAULT, ?, ?, ?)";

Zapytanie podstawiamy do adaptera używając metody PDO::prepare()

$sth = $dbh->prepare($sql);

Jeśli wszystko przebiegło prawidłowo prepare() zwraca nowy obiekt PDOStatement reprezentujący zbiór danych. Jak to działa najlepiej przedstawia kod:

$sth->bindParam(':imie', $_POST['imie'], PDO::PARAM_STR);
$sth->bindParam(':nazwisko', $_POST['nazwisko'], PDO::PARAM_STR);
$sth->bindParam(':wiek', $_POST['wiek'], PDO::PARAM_INT, 3);

Do wcześniej nazwanych parametrów podstawiliśmy dane z formularza wysłanego metodą POST, wskazaliśmy iż są one stringiem bądź integerem, a w przypadku parametru `:wiek` ustawiliśmy również długość. Tak przygotowane dane są bezpieczne dla operacji SQL więc można je wykonać.

$sth->execute();

Dla naszego drugiego zapytania używającego ? jako parametrów dane do metody execute() możemy podstawić używajac tablicy:

$sth->execute(array($_POST['imie'], $_POST['nazwisko'], $_POST['wiek']));

Kolejny element tablicy odpowiada następnemu znakowi ?. Zapytanie możemy wykonywać tak długo, jak istnieje obiekt PDOStatement.

$sql = "INSERT INTO uzytkownicy VALUES(DEFAULT, :imie, :nazwisko, :wiek)";
$sth = $dbh->prepare($sql);
$sth->execute(array('Tomasz', 'Ziółkowski', 45));
$sth->execute(array('Ireneusz', 'Ziółkowski', 49));
$sth->execute(array(':imie' => 'Roman', ':nazwisko' => 'Wesołek', 'wiek' => 11));

Teraz, kiedy wstawiliśmy już trochę danych możemy spróbować je pobrać.

$sql = "SELECT * FROM uzytkownicy";
$sth = $dbh->prepare($sql);
$sth->execute();
$result = $sth->fetchAll();
print_r($result);

Z wyniku możemy wyświetlić tylko dane kolumny `imie`:

$result = $sth->fetchAll(PDO::FETCH_COLUMN, 1);
Array ( [0] => Jan [1] => Piotr [2] => Tomasz [3] => Ireneusz [4] => Roman ) 

Albo dodać klauzulę WHERE:

$sql = "SELECT * FROM uzytkownicy WHERE imie ~* ?";
$sth = $dbh->prepare($sql);
$sth->execute(array('oma'));
$result = $sth->fetchAll(PDO::FETCH_OBJ);
print_r($result);

Inną, jeszcze jedną metodą wybierania danych jest konstrukcja PDO::query().

$sql = "SELECT * FROM uzytkownicy WHERE imie ~*".$dbh->quote('oma', PDO::PARAM_STR);
foreach ($dbh->query($sql) as $row) {
  print $row['imie'] . ' ' . $row['nazwisko'] . '<br>';
}

Pamiętaj aby zawsze bezwzględnie używać PDO::quote() w połączeniu z PDO::query() dla danych pochodzących bezpośrednio od użytkownika.

Nie da się oczywiście opisać wszystkich metod i stałych występujących w klasie PDO dlatego zapraszam do lektury dokumentacji i eksperymentowania. Zwłaszcza z wyciąganiem bloków danych za pomocą PDO::ATTR_CURSOR.

Transakcje

Idea tradycyjnych, relacyjnych baz danych polega na zapewnieniu przewidywalnego stanu dla operacji. Wyobraź sobie sytuację w której przelewasz innej osobie pieniądze, wypłynęły już one z Twojego konta ale z powodu błędu w banku odbiorcy nie mogły trafić na jego rachunek. Oczywistym jest iż powinny być zwrócone na Twoje konto. Stan taki realizowany jest za pomocą transakcji.

Transakcje nie są dostępne dla domyślnego silnika bazy MySQL – MyISAM

Dopiszmy jeszcze dwie tabele:

CREATE TABLE dane.transakcje
(
  id serial NOT NULL,
  kwota money NOT NULL,
  CONSTRAINT transakcje_pkey PRIMARY KEY (id )
);

CREATE TABLE dane.transakcje_uzytkownicy
(
  nadawca integer NOT NULL,
  odbiorca integer NOT NULL,
  transakcja integer NOT NULL,
  id serial NOT NULL,
  CONSTRAINT transakcje_uzytkownicy_pkey PRIMARY KEY (id ),
  CONSTRAINT transakcje_uzytkownicy_nadawca_fkey FOREIGN KEY (nadawca)
      REFERENCES dane.uzytkownicy (id) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT transakcje_uzytkownicy_odbiorca_fkey FOREIGN KEY (odbiorca)
      REFERENCES dane.uzytkownicy (id) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT transakcje_uzytkownicy_transakcja_fkey FOREIGN KEY (transakcja)
      REFERENCES dane.transakcje (id) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION,
  CONSTRAINT uqtrans UNIQUE (nadawca , odbiorca , transakcja )
);

Aby wstawić dane w transakcji należy wykorzystać wyjątki. Jeżeli baza nie zanotowała żadnego błędu transakcja jest zatwierdzona (commit), w przeciwnym wypadku następuje cofnięcie (rollback).

try {
  $dbh->beginTransaction();
  $sqlTrans = "INSERT INTO transakcje(kwota) VALUES(50);";
  $dbh->exec($sqlTrans);
  $idTransakcji = $dbh->lastInsertId('transakcje_id_seq');
  $sqlUzTrans = "INSERT INTO transakcje_uzytkownicy VALUES(2, 6, $idTransakcji, DEFAULT)";
  $dbh->exec($sqlUzTrans);
  $dbh->commit();
  echo 'Przelew zatwierdzony';
} catch(PDOException $e) {
  $dbh->rollBack();
  echo 'Przelew odrzucony: '.$e->getMessage();
}

lastInsertId() zwraca ostatnią wartość transakcji odczytaną z sekwencji. W bazie PostgreSQL można również wykorzystać składnię:

INSERT INTO transakcje(kwota) VALUES(50) RETURNING id;

Możemy jeszcze sprawdzić czy przelew został wykonany:

SELECT kwota, un.imie || ' ' || un.nazwisko AS nadawca, uo.imie || ' ' || uo.nazwisko AS odbiorca
FROM transakcje t
INNER JOIN transakcje_uzytkownicy tu ON tu.transakcja = t.id
INNER JOIN uzytkownicy un ON tu.nadawca = un.id
INNER JOIN uzytkownicy uo ON tu.odbiorca = uo.id;

Podsumowanie

Udało nam się omówić pokrótce większość metod dostępnych w rozszerzeniu PDO. Jeżeli porównać kod rozszerzeń bazodanowych standardowych z klasą PDO widać wyraźnie jak potężne możliwości dane nam ono do ręki. Po przezwyciężeniu podstawowych problemów początkujących programistów związanych z obiektami, pisanie zapytań SQL staje się przyjemne, jest mniej szans na popełnienie błędów a same zapytania są lepiej zabezpieczone. Oczywiście PDO to tylko początek. Przechodząc dalej do frameworków mamy możliwość korzystania np z Zend_Db i Zend_Db_Table. Potężnego, choć trochę ociężałego projektu Doctrine albo całkiem ciekawego mapera Propel. Wszystkie rozszerzają podstawowe możliwości PDO i wiedza którą starałem się Wam przekazać jest niezbędna, żeby z nich skutecznie korzystać.

Komentarze

Nie ma jeszcze żadnych komentarzy do wyświetlenia. Może chcesz zostać pierwszą osobą która podzieli się swoją opinią?

Dodaj komentarz

*
Nazwa zostanie wyświetlona wraz z komentarzem. Możesz też utworzyć nowe konto w serwisie, dzięki czemu uzyskasz dodatkową funkcjonalność.
*
Akceptowana jest ograniczona składnia Textile. Wszystkie tagi HTML zostaną usunięte.