ActiveRecord по Фаулеру, как он есть

fixxxer

К.О.
Партнер клуба
Прямой порт описания паттерна из PoEAA на php. Небольшую вольность позволил себе только с ActiveRecord::load.

PHP:
<?php

class ActiveRecord {

    protected static $db;

    public static function setDb(Pdo $db) {
        self::$db = $db;
    }

    public static function load(array $resultset) {
        $instance = new static;
        foreach ($resultset as $key => $value) {
            if (property_exists($instance, $key)) {
                $instance->$key = $value;
            }
        }
        return $instance;
    }

}

class User extends ActiveRecord {

    const FIND_BY_ID_STMT = "SELECT id, email, name FROM User WHERE id = :id";
    const FIND_BY_EMAIL_STMT = "SELECT id, email, name FROM User WHERE email = :email";

    const INSERT_STMT = "INSERT INTO User (name, email) VALUES (:name, :email)";
    const UPDATE_STMT = "UPDATE User SET name = :name, email = :email WHERE id = :id";
    const DELETE_STMT = "DELETE FROM User WHERE id = :id";

    protected $id;
    protected $name;
    protected $email;

    public function getId() {
        return $this->id;
    }

    public function setId($id) {
        $this->id = $id;
    }

    public function getName() {
        return $this->name;
    }

    public function setName($name) {
        $this->name = $name;
    }

    public function getEmail() {
        return $this->email;
    }

    public function setEmail($email) {
        $this->email = $email;
    }

    public static function findById($id) {
        $stmt = self::$db->prepare(self::FIND_BY_ID_STMT);
        $stmt->bindParam(':id', $id, PDO::PARAM_INT);
        $stmt->execute();
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? self::load($row) : null;
    }

    public static function findByEmail($email) {
        $stmt = self::$db->prepare(self::FIND_BY_EMAIL_STMT);
        $stmt->bindParam(':email', $email, PDO::PARAM_STR);
        $stmt->execute();
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? self::load($row) : null;
    }

    public function insert() {
        $stmt = self::$db->prepare(self::INSERT_STMT);
        $stmt->bindParam(':email', $this->email, PDO::PARAM_STR);
        $stmt->bindParam(':name', $this->name, PDO::PARAM_STR);
        $stmt->execute();
        return $this->id = self::$db->lastInsertId();
    }

    public function update() {
        if (!isset($this->id)) {
            throw new LogicException("Cannot update(): id is not defined");
        }
        $stmt = self::$db->prepare(self::UPDATE_STMT);
        $stmt->bindParam(':id', $this->id, PDO::PARAM_INT);
        $stmt->bindParam(':email', $this->email, PDO::PARAM_STR);
        $stmt->bindParam(':name', $this->name, PDO::PARAM_STR);
        $stmt->execute();
    }

    public function delete() {
        if (!isset($this->id)) {
            throw new LogicException("Cannot delete(): id is not defined");
        }
        $stmt = self::$db->prepare(self::DELETE_STMT);
        $stmt->bindParam(':id', $this->id, PDO::PARAM_INT);
        $stmt->execute();
        $this->id = null;
    }

}

class ArUserTest extends PHPUnit_Framework_TestCase {

    public function setUp() {
        $db = new \Pdo('mysql:host=localhost;dbname=test', 'test', 'test');
        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $db->query('DROP TABLE IF EXISTS User');
        $db->query(
            'CREATE TABLE User (
                id integer not null auto_increment,
                email varchar(255),
                name varchar(255),
                PRIMARY KEY (id),
                UNIQUE (email)
            )'
        );
        ActiveRecord::setDb($db);
    }

    public function test() {
        $User = new User;
        $User->setEmail('[email protected]');
        $User->setName('test user');
        $id = $User->insert();
        $this->assertEquals(1, $id);

        $User = User::findById(1);
        $this->assertEquals(1, $User->getId());
        $this->assertEquals('[email protected]', $User->getEmail());
        $this->assertEquals('test user', $User->getName());

        $User = User::findByEmail('[email protected]');
        $this->assertEquals(1, $User->getId());
        $this->assertEquals('[email protected]', $User->getEmail());
        $this->assertEquals('test user', $User->getName());

        $User->setEmail('[email protected]');
        $User->setName('test2 user');
        $User->update();

        $User = User::findById(1);
        $this->assertEquals(1, $User->getId());
        $this->assertEquals('[email protected]', $User->getEmail());
        $this->assertEquals('test2 user', $User->getName());

        $User->delete();

        $this->assertNull($User->findById(1));
    }

}
Да, именно это называется ActiveRecord.

Так что, давайте прекращать ссылаться на Фаулера в контексте RoR-подобных реализаций AR (yii, eloquent итд), окей? ;)
 

Вурдалак

Продвинутый новичок
Очередной antipattern. Нарушение SRP и tight coupling.

Как обычно будет оправдание, что для простых проектов самое оно, но в этом и смысл antipattern'ов, что поначалу кажется, что это подходит идеально для решения задачи здесь и сейчас.
 

MiksIr

miksir@home:~$
Мда. Была болезнь "паттернизм", а теперь есть и болезнь "антипаттернизм".
 

fixxxer

К.О.
Партнер клуба
Вурдалак, Фаулер так и пишет:
It's easy to build Active Records, and they are easy to understand. Their primary problem is that they work well only if the Active Record objects correspond directly to the database tables: an isomorphic schema. If your business logic is complex, you'll soon want to use your object's direct relationships, collections, inheritance, and so forth. These don't map easily onto Active Record, and adding them piecemeal gets very messy.
Собственно, все попытки сделать из тупого как пробка isomorphic-schema-AR что-то продвинутое, со связями и коллекциями, неизбежно приводят к "very messy" коду, в чем несложно убедиться, заглянув в исходники любой реализации RoR-подобного AR. ;)
 

Василий М.

Новичок
1. Вместо явных методов используем сеттеры: http://www.phpinfo.su/articles/practice/settery_v_php_pravilnoe_ispolzovanie.html
2. Данные модели не должны быть членами класса, это не нужно абсолютно. Пусть хранятся в private array.
3. Вместо
PHP:
return $row ? self::load($row) : null;
возвращаем пустой объект с ID = null (или ноль) - это удобно.
4. Save и Update объединяем в метод save()
 

Василий М.

Новичок
кстати, я насчет связанность вам позже напишу как это решается просто и элегантно. никаких фитч в стиле доктрины - все предельно просто и весьма продуктивно. ждите.
 

Василий М.

Новичок
Собственно, как и обещал.


На одной из работ, я с трудом понимал Доктрину, я был чрезвычайно не доволен сложностью свзяей в ORM.
Фактически, я согласен с Fixxer-ом: "все попытки сделать из тупого как пробка isomorphic-schema-AR что-то продвинутое, со связями и коллекциями, неизбежно приводят к "very messy" коду, в чем несложно убедиться, заглянув в исходники любой реализации RoR-подобного AR"

Когда я реализовал свой Data Mapper, я был очень рад тому, что могу писать примерно так:
PHP:
$user = $userMapper->findByEmail('[email protected]');
// а потом и так:
echo $user->getEmail()->getDomain(); // mail.ru
echo $user->getFullName(); // Петя Иванов
Это все давало потрясающие выгоды - инкапсуляция бизнес-логики объектов в их классах, настоящее ООП!
Мапперы выступали лишь в роли оболочек, которые либо принимали объекты и писали их в базу, либо возвращали объекты данных предметной области.

Потом встал вопрос о том, как в шаблоне вывести список пользователей и их города/регионы.
Куждая сущьность в моей архитектуре, была описана с помощью Модели. И Пользователь и Регион и Город.
Естественно я начал хотеть получить такой код:
PHP:
$user->getRegion()->getId();
или даже так:
PHP:
$user->getRegion()->getCity()->getId();
вскоре я плюнул на это дело и добился очень простого, но абсолютно приемлемого для меня решения:

PHP:
class User_Mapper {
   public function getListWithRegion() {
     $sql = 'SELECT * FROM user INNER JOIN region ON ...';
     return $this->result2objects($sql);
   }
Метод result2objects выполнял запрос, на основани имен таблиц, учавствующих в запросе, запрашивал конкретные классы моделей,
инстанцировал их, после чего наполнял данными. Фактически, я получал такую структуру объектов предметной области:
PHP:
$data = $user_mapper->getListWithRegion();
// $data[0]['user'] => object User
// $data[0]['region'] => object Region
// ...
Да, объект пользователя $data[0]['user'] не знает о своем регионе ничего, кроме его идентификатора. Но в данном контектсе это ему и не нужно особо, т.к. $data[0]
содержит массив всех необходимых объектов, выбранных по JOIN.

Конечно, в каждой модели у меня живет Mapper_Manager и я всегда по факту надобности могу написать:
PHP:
class User {
   public function findRegion() {
     $this->mapper_manager->getMapper('Region/City')->findById(
        $this->getId() // а тут, кстати говоря, магический метод - метода getId не существует ни в одной моей модели 
    );
     // ...
но как правило такие запросы даже не нужны.
 
  • Like
Реакции: Dez

fixxxer

К.О.
Партнер клуба
Василий М., я к похожей архитектуре пришел. У меня метод, похожий на твой result2objects, работает уже с полученным датасетом (если ты умеешь на лету в fetch-цикле - ну круто, так эффективнее), раскидывает и создает модели и коллекции. Ну еще lazy load-ом все приправлено.
 

grigori

( ͡° ͜ʖ ͡°)
Команда форума
есть 2 подхода: или описываем структуру данных в моделях - doctrine, а в django даже миграции в базе строятся под изменения моделей,
или строим модели на основе структуры базы - yii, кто там еще
ваш подход - это первый вариант, желание автоматизировать базовые операции приведет к описанию структуры в моделях
 

Василий М.

Новичок
желание автоматизировать базовые операции приведет к описанию структуры в моделях
у меня без этого никак. в моделях описана структура - начиная с дефолтных значений и кончая примитивными валидаторами данных:

PHP:
<?php
class Krugozor_Module_User_Model_User extends Krugozor_Model
{
    protected static $model_attributes = array
    (
        'id' => array(
            'db_element' => false,
            'default_value' => 0,
            'validators' => array(
                'Common/Decimal' => array('signed' => true),
            )
        ),

        'unique_cookie_id' => array(
            'db_element' => true,
            'db_field_name' => 'user_unique_cookie_id',
            'validators' => array(
                'Common/StringLength' => array('start' => 0, 'stop' => Krugozor_Module_Common_Validator_StringLength::MD5_MAX_LENGTH),
                'Common/CharPassword' => array(),
            )
        ),

        'active' => array(
            'db_element' => true,
            'db_field_name' => 'user_active',
            'default_value' => 1,
            'validators' => array(
                'Common/EmptyNull' => array(),
                'Common/Decimal' => array('signed' => false),
                'Common/IntRange' => array('min' => 0, 'max' => 1),
            )
        ),

        'group' => array(
            'db_element' => true,
            'db_field_name' => 'user_group',
            'default_value'=> 2, // 2 - ID группы Пользователи
            'validators' => array(
                'Common/Empty' => array(),
                'Common/Decimal' => array('signed' => false),
            )
        ),

        'login' => array(
            'db_element' => true,
            'db_field_name' => 'user_login',
            'validators' => array(
                'Common/EmptyNull' => array(),
                'Common/StringLength' => array('start' => 0, 'stop' => Krugozor_Module_Common_Validator_StringLength::VARCHAR_MAX_LENGTH),
                'Common/CharPassword' => array(),
            )
        ),

        'email' => array(
            'type' => 'Krugozor_Module_Common_Type_Email',
            'db_element' => true,
            'default_value' => null,
            'db_field_name' => 'user_email',
            'validators' => array(
                'Common/StringLength' => array('start' => 0, 'stop' => Krugozor_Module_Common_Validator_StringLength::VARCHAR_MAX_LENGTH),
                'Common/Email' => array(),
            )
        ),
 
Последнее редактирование:

fixxxer

К.О.
Партнер клуба
'type' => 'Krugozor_Module_Common_Type_Email',
Вот это, конечно, жесть. Но единственное вменяемое решение - переходить на 5.5, где есть ::class.

Как минимум неплохо было бы сократить по аналогии с валидаторами.
 

Василий М.

Новичок
Вот это, конечно, жесть. Но единственное вменяемое решение - переходить на 5.5, где есть ::class.
в чем жесть? у меня есть тип - email.
Когда я делаю выборку, но у меня в объекте Модели там не просто строка, а объект Email:

PHP:
class Krugozor_Module_Common_Type_Email implements Krugozor_Module_Common_Type_Interface
{
    /**
     * Email адрес.
     *
     * @var string
     */
    protected $email;

    public function __construct($email)
    {
        $this->setValue($email);
    }

    public function getValue()
    {
        return $this->email;
    }

    public function setValue($value)
    {
        $this->email = $value;
    }

    /**
     * Возвращает md5 хэш. Данный хэш, пришедший из запроса,
     * сравнивается в контроллере Krugozor_Module_Ajax_Controller_AdvertEmail с хэшем,
     * возвращённым этим методом. Если хэши совпадают, то данные отдаются пользователю.
     * Данное сравнению нужно для предотвращения автоматической сборки email адресов по адресу
     * /ajax/advert-email/id/[0-9]+
     *
     * @param void
     * @return string
     */
    public function getMailHashForAccessView()
    {
        return md5($this->getValue() . Krugozor_Registry::getInstance()->SECURITY['SALT']);
    }
}
а что там в 5.5?
 

grigori

( ͡° ͜ʖ ͡°)
Команда форума
>в чем жесть?
В магической строковой константе, которая не обрабатывается в IDE, не автокомплитится, не рефакторится, и при удалении про это место можно не найти. Я сегодня как-раз исправлял такой баг.
Когда-то из-за этих строковых констант с именами классов я не смог принять ZF и Symfony.

в 5.5 появилось http://www.php.net/manual/en/language.oop5.basic.php#language.oop5.basic.class.class
надо бы проверить, грузится ли сам класс при этом синтаксисе, т.е. можно ли так писать в конфиге

upd: нет, не грузятся, можно так писать для несуществующих классов. отлично!
 
Последнее редактирование:

Absinthe

жожо
Когда-то из-за этих строковых констант с именами классов я не смог принять ZF и Symfony.
1. В симфони сейчас такого нет.
2. Есть плагины фреймворков в IDE, которые дополняют автокомплит. К примеру строки в описании сервисов.
 

grigori

( ͡° ͜ʖ ͡°)
Команда форума
да я про 5 лет назад, про 1й симфони, про описание зависимостей с чудесным синтаксисом для классов и параметров
 
Сверху