0

Piszemy własnego ORMa – mapowanie obiektowo-relacyjne w PHP [Część I]

Piszemy własny ORM w PHP

Jak wspominałem w jednym z poprzednich artykułów, obecnie istniejące mapowania relacyjno-obiektowe dla języka PHP mają sporo niedociągnięć, które utrudniają prostą i bezproblemową komunikację z bazą danych. Pora powoli przejść od słów do czynów i pokazać kod źródłowy, który mógłby znacznie poprawić ten stan rzeczy.

Szkielet aplikacji

Do bezpośredniej komunikacji z bazą danych użyłem biblioteki PDO, gdyż w zasadzie nie istnieje dla niej żadna porządna alternatywa. W związku z tym skorzystam z paru specyficznych metod, które niekoniecznie będą występowały w innych rozwiązaniach. Cała aplikacja będzie korzystała z jednej instancji połączenia do bazy danych zrealizowanej przy pomocy singletona.

Każdy model w aplikacji będzie musiał rozszerzać klasę Model, a przez to implementować parę interfejsów, które np. pozwolą wskazać nazwę tabeli czy poprawną strukturę tabeli bazodanowej. Wstępnie docelowy model może wyglądać następująco (dla czytelności kodu będę tutaj prezentował tylko najważniejsze fragmenty kodu – całość będzie dostępna w repozytorium Gita):

class User extends Model
{
    protected $id;

    protected $title;

    protected $created;

    protected $updated;

    /**
    * @return Table
    */
    function __toTable(): Table
    {
        $table = new Table();

        $field = new Column();
        $field->setFieldName('id');
        $field->setPrimaryKey(true);
        $field->setValue($this->getId());
        $field->setDataType(DataType::INTEGER);
        $table->addColumn($field);

        $field = new Column();
        $field->setFieldName('name');
        $field->setValue($this->getTitle());
        $field->setDataType(DataType::STRING);
        $table->addColumn($field);

        $field = new Column();
        $field->setFieldName('created');
        $field->setValue($this->getCreated());
        $field->setDataType(DataType::DATETIME);
        $table->addColumn($field);

        $field = new Column();
        $field->setFieldName('updated');
        $field->setValue($this->getUpdated());
        $field->setDataType(DataType::DATETIME);
        $table->addColumn($field);

        return $table;

    }

    /**
    * @return string
    */
    function __tableName(): string
    {
        return 'users';
    }

    /**
     * @return integer
     */
    public function getId() : integer
    {
        return $this->id;
    }

....

}

Plusem takiego rozwiązania jest rezygnacja z adnotacji, które moim zdaniem nie powinny mieć wpływu na wykonywanie skryptu (nazwę tabeli oraz pól możemy pobierać przy pomocy metod). Minusem jest natomiast konieczność dość sporej ilości kodu w metodzie __toTable(). W jednej z przyszłych wersji tego ORMa będzie można dodać generowanie takiego kodu z bazy danych – podobne generatory istnieją od dawna w konkurencyjnych projektach. Wtedy taka implementacja powinna być całkiem przyjemna i przejrzysta.

Mapowanie obiekt – relacja oraz relacja-obiekt

Mając modele określone jak powyżej powinniśmy móc bez problemów konwertować je na tablice gotowe do zapisania do bazy danych i odwrotnie. Nie jest żadnym problemem napisanie dwóch prostych metod (a w zasadzie klas) konwertujących:

 public function map($object) : array{
        $resultArray = [];

        if (!is_subclass_of($object, Model::class)){
            throw new \Exception('Each model should be subclass of VivoDB\Model\Model');
        }

        /** @var Model $object */
        $properties = $object->get_object_vars();

        foreach ($properties as $key => $value){
            $resultArray[$key] = $value;
        }

        return $resultArray;
    }


// -----------


public function map($object, Array $array)
    {

        foreach ($array as $key => $value) {
            if (property_exists($object, $key)) {
                $object->key = $value;
            } else {
                 throw new \Exception('Provided object has no property: ' . $key);
            }
        }

        return $object;
    }

Co więcej, pisanie takich metod może się okazać nawet niepotrzebne. Biblioteka PDO zawiera od razu zaimplementowany tryb pracy, który przypisuje wynik do wskazanego obiektu:

$pdoStatement = $this->db->query($sql);
$pdoStatement->setFetchMode(PDO::FETCH_CLASS|PDO::FETCH_PROPS_LATE, User::class);

Czy to wszystko?

Jeśli nawet powyższa implementacja pokrywałaby ścisłą definicję pojęcia ORM to w praktyce oczekujemy przecież możliwości wykonywania zapytań do bazy danych w oparciu o skonstruowany przez nas mechanizm. Czy komplikuje to cały problem? Pewnie. Czy da się to prosto zrobić? Zobaczmy.

Język SQL składa się z kilku „podjęzyków”, tj. DQL, DML, DDL. Pierwszym pomysłem jest, aby model implementował interfejsy odpowiadające tym podjęzykom. To zadanie jest proste i nie będzie wymagało konkretnych implementacji modeli, a więc w modelu (bazowym) możemy dodać np. metodę implementującą jedną z metod interfejsu odpowiadającego zapytaniom DML:

public function insert(): string
 {
     $dml = new DML();
     return $dml->insert($this);
 }

Będzie nas to odwoływało do takiej oto metody (w najprostszej postaci):

public function insert(ModelToArray $object): string
   {

       $tableData = $this->objectHelper->resolveObject($object);

       $keys = [];
       $names = [];

       /** @var Column $column */
       foreach ($tableData->getColumns() as $column) {
           $key = $column->getFieldName();

           $keys[] = "`$key`";
           $names[] = ":$key";
       }

       $keys = implode(',', $keys);
       $names = implode(',', $names);

       $db = DB::getDB();
       $tableName = $tableData->getTableName();
       $stmt = $db->prepare("INSERT INTO `$tableName` ($keys) VALUES ($names)");

       $stmt = $this->statementHelper->bindParams($tableData, $stmt);

       $this->statementHelper->execute($stmt);

       return $db->lastInsertId();
   }

W analogiczny sposób możemy zaimplementować pozostałe metody języka DML (np. insertOrIgnore(), update()), a także możemy je ulepszyć zezwalając na podawanie argumentów, które wpleciemy do naszego zapytania w PDO (np. WHERE). Język DDL (np. CREATE, DROP) będzie nieco podobny do powyższej metody, ale zamiast na obiekcie to będzie operował na tabeli.

Znacznie ciekawsza sytuacja jest z językiem DQL, gdyż każdy ORM inaczej podchodzi do tego tematu. Widzę główne trzy konstrukcje stosowane do tego celu:

// MeekroDB
$result = DB::queryFirstRow('SELECT * FROM users WHERE id = %d', 5);

// Eloquent
$result = App\User::where('id', 5)->first();

// Doctrine
$result = $entityManager->getRepository('User')
                         ->findOneBy(array('id' => 5));

Szczerze? Według mnie każde podejście ma swoje plusy i minusy:

  • MeekroDB stosuje SQL podany praktycznie jako string (w sposób zbliżony do parametrów przyjmowanych przez funkcję sprintf()). Na pewno staje się przez to przejrzysty i praktycznie nie ma ograniczeń – można w ten sposób wywołać praktycznie każde zapytanie DQL. Podawanie jednak nazw tabel czy kolumn jako string nijak mi się nie podoba.
  • Eloquent ma znacznie bardziej obiektową składnię, ale jednak wprowadza swoją syntaktykę (bo przecież SQLowy „LIMIT” jest zbyt mainstreamowy). Kiepsko radzi sobie z bardzo komplikowanymi zapytaniami.
  • Doctrine podobnie jak Eloquent wprowadza masę typowych dla siebie metod, co tylko utrudnia sprawne poruszanie się w tym ORMie. Niektórych zapytań nie da się wykonać w ten sposób, przez co trzeba korzystać z Query Buildera (który również nie zapewnia pełnej obsługi DQLa (tzn. tego prawdziwego DQLa)).

Jakie jest moje zdanie na ten temat? Nie zakochałem się w żadnym z powyższych podejść (chociaż czytelność i prostota MeekroDB dają mu przewagę), a więc będę drążył temat w kolejnym artykule, gdyż zadanie wydaje się mocno skomplikowane.

Ej, ej, a co z relacjami?

W zasadzie na tej podstawie można by już zaimplementować w miarę działający ORM (zakładając, że DQL zrealizowalibyśmy np. jako „prepare statements” do PDO, albo w jakiś sposób realizowali je podobnie jak język DML). Dość mocnym problemem są natomiast relacje. Ich wskazanie w modelu już samo w sobie jest niezbyt wygodne (przede wszystkim dlatego, że teoretycznie powinniśmy używać tablic, a w związku z tym tracimy podpowiedzi, gdyż PHP nie umożliwia zadeklarowania typu tablic), a ich odpowiednia implementacja i obsługa błędów (czy jakiś ORM sprawdza sensowność relacji?) jest zdecydowanie kłopotliwa. Tak więc na ten temat również poświęcę osobny artykuł, gdy tylko uda mi się uporać ze wskazanymi problemami 🙂

 

 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *