Od kiedy programiści stron internetowych [i nie tylko] dostali w swoje ręce pierwsze biblioteki wspomagające technikę ORM - mapowania obiektowo-relacyjnego, stało się jasne, że w bardzo wielu przypadkach oszczędzą nam one samodzielnego pisania zapytań SQL i związanych z tym problemów i błędów. Za bardzo przyjemną abstrakcją tychże bibliotek kryje się jednak jeden problem - ze względu na fakt, iż próbuje ona zastąpić nam całkowicie leżącą niżej warstwę bazy danych, bardzo często nie mamy możliwości skorzystania z natywnych funkcji SQLa. W niniejszym wpisie pokażę, jak sobie z tym poradzić wykorzystując bibliotekę ORM Doctrine.

Fotografia: juokaz, CC-BY-SA.

Doctrine: Dosłowne wyrażenia i funkcje SQL.

Mówiąc o Doctrine nie ucieknę oczywiście od powoływania się na mechanizmy obecne we frameworku symfony, ponieważ jest to główny ekosystem, w jakim pracuję z tą biblioteką. Załóżmy zatem milcząco, że mamy jakieś pojęcie o tym, co się mniej więcej w symfony dzieje, aczkolwiek postaram się jednak za bardzo tej kwestii nie komplikować.

Weźmy pod uwagę następujący model opisany fragmentem pliku YAML:

CustomModel:
  tableName: customModels
  columns:
    id:
      type: integer(4)
      primary: true
      notnull: true
      autoincrement: true
    name:
      type: string(255)
    description:
      type: clob(65535)
    createdAt:
      type: timestamp

Wychodząc na przeciw pytaniu “co my tu mamy”, załóżmy, że naszym CustomModelem jest produkt, który posiada:

  • identyfikator i klucz główny id,
  • nazwę name,
  • opis description,
  • datę i czas dodania do bazy danych.
Spróbujmy zatem dodać rekord do bazy danych, korzystając z klas wygenerowanych poprzez narzędzie symfony CLI. Klasa bazowa będzie wyglądała mniej więcej tak:

<?php
abstract class BaseCustomModel extends sfDoctrineRecord
{
    public function setTableDefinition()
    {
        $this->setTableName('customModels');
        $this->hasColumn('id', 'integer', 4, array(
             'type' => 'integer',
             'primary' => true,
             'autoincrement' => true,
             'length' => 4,
             ));
        $this->hasColumn('name', 'string', 255, array(
             'type' => 'string',
             'length' => 255,
             ));
        $this->hasColumn('description', 'clob', 65535, array(
             'type' => 'clob',
             'length' => 65535,
             ));
        $this->hasColumn('createdAt', 'timestamp', null, array(
             'type' => 'timestamp',
             ));
        $this->option('collate', 'utf8_general_ci');
        $this->option('charset', 'utf8');
        $this->option('type', 'InnoDB');
    }

    public function setUp()
    {
        parent::setUp();
    }
}

Oraz sama [jakże skomplikowana!] klasa modelu:

class CustomModel extends BaseCustomModel
{

}

A kod, którego potencjalnie użyjemy będzie prezentował się następująco:

// (...)
$customModel = new CustomModel();
$customModel->name = 'such a beautiful name';
$customModel->description = 'very good, solid piece of hardware';
$customModel->timestamp = date("Y-m-d H:i:s");
$customModel->save();

Jak się zapewne domyślacie, problematyczne jest pole typu TIMESTAMP, któremu trzeba podać odpowiednie dane do aktualizacji / wstawienia danego rekordu. Skoro jednak jest funkcja date(), to w czym problem? Nie jest to może jakiś błąd w sztuce programistycznej, aczkolwiek w kwestii daty / czasu o wiele lepiej jest polegać na danych pochodzących bezpośrednio z serwera SQL. Lepiej będzie, jeśli wykorzystamy jego wewnętrzne mechanizmy pobierania tych informacji. Dlatego też zamiast podawać bezpośrednio gotowy string z danymi, lepiej byłoby skorzystać z jednego z wariantów aktualizacji pola czasowego w MySQL:

  • funkcji NOW(),
  • funkcji CURRENT_TIMESTAMP(),
  • funkcji UNIX_TIMESTAMP().
Co jednak się stanie, jeśli zamiast rzeczonej linijki:

$customModel->timestamp = date("Y-m-d H:i:s");

wstawimy tą:

$customModel->timestamp = 'NOW()';

? Nasz skrypt wykona wtedy dosyć nieprzyjemne w skutkach zapytanie:

INSERT INTO customModels (id, name, description, createdAt)
VALUES (NULL, 'such a beautiful name', 'very good, solid piece of hardware', 'NOW()')

Co w konfrontacji z polem TIMESTAMP zapisze w rekordzie wartość 0000-00-00 00:00:00. Okazuje się, że wstawienie własnej, literalnej wartości / fragmentu kodu do zapytania SQL nie jest możliwe w ramach zazwyczaj używanych elementów biblioteki Doctrine.

Okazuje się jednak, że można to zrobić w relatywnie prosty sposób - trzeba tylko wiedzieć jak. ;] Mamy tu kolejny klasyczny przykład problemu związanego z “wiedzą”, bowiem za wstawianie literałów do zapytania odpowiada klasa Doctrine_Expression. Aby z niej skorzystać, należy podmienić błędną linijkę na następującą:

$customModel->timestamp = new Doctrine_Expression('NOW()');

W tym momencie w zapytaniu pojawi się już klasyczna funkcja NOW() nie zamknięta w broniących przed SQL Injection ciapkach, dzięki czemu w wynikowym rekordzie otrzymamy takie dane, jakich oczekiwaliśmy. Oczywiście w ten sposób można umieścić dowolną inną funkcję SQL w zapytaniu do bazy danych, aczkolwiek problem z NOW() wydaje mi się najczęstszym, jaki pojawia się w programowaniu stron internetowych.

Dziękuję za miło spędzony czas i zapraszam do dyskusji na temat innych sposobów na osiągnięcie tego celu. Biblioteka Doctrine jest na tyle wielkim tworem, że na pewno można sobie poradzić z tym problemem wykorzystując inne rozwiązania. Przy okazji - mała niespodzianka - kolejnym wpisem nie będzie Linkdump, ale coś nieco innego - zapraszam do zgadywania. ;]