Особенности метода xPDOObject::save() + транзакции

    Совсем недавно Сергей Прохоров ака proxyfabio написал статью Валидация объектов + транзакции. Немного эта тема обсуждалась здесь. От себя хочу добавить, что эта тема крайне важная, и на сегодня это одна из самых главных проблем в разработке крупных проектов на MODX Revolution.

    Здесь сразу попрошу не начинать ничего вроде «Если делаете крупные проекты, не надо их делать на MODX, возьмите бла-бла-бла». Мы делали крупные проекты, и не только на MODX. На MODX вполне можно делать крупные проекты, и на сегодня есть всего лишь пара слабых мест, которые мы правим на индивидуальных проектах, в остальном же MODX на 98% пригоден для разработки крупных проектов.

    Итак, одна из этих серьезных проблем связана именно с методом xPDOObject::save() (вызываемая при сохранении xPDO-объектов). Суть этой проблемы в том, что внутри него срабатывает метод сохранения связанных объектов xPDOObject::_saveRelatedObjects() дважды. Раз и два. Делается это для того, чтобы выставить первичные и вторичные ключи для этих связанных объектов (см. справочный материал от Ильи Уткина). Объясню подробней на примере. Вот код:
    <?php
    $user_data = array(
        "username"  => "test",
    );
    
    $profile_data = array();
    
    $user = $modx->newObject('modUser', $user_data);
    $user->Profile = $modx->newObject('modUserProfile', $profile_data);
    $user->save();
    
    print '<pre>';
    print_r($user->toArray());
    print_r($user->Profile->toArray());
    


    В целом наверняка суть этого кода понятна многим, но давайте сосредоточимся на деталях. Когда мы создали два новых объекта ($user и $user->Profile), у них еще нет айдишников, пока их не сохранили. Но сохранив только объект $user, мы на выходе получаем и сохраненный объект $user->Profile. Это как бы тоже понятно почему, Илья в своей статье все это описывает. Но вопрос, который не совсем на виду болтается — это «как xPDO „знает“ какой id у объекта $user, чтобы назначить этот id в качестве $modx->Profile->internalKey?». Для этого давайте опять-таки пробежимся по коду метода xPDO::save();

    Вот у нас первый вызов метода $user->_saveRelatedObjects(). В этот момент объект $user еще не сохранен (не записан в базу), id-шника у него еще нет. $user->Profile тоже не сохранен и не имеет ни id, ни internalKey. Переходя к вызову метода $user->_saveRelatedObjects(), мы видим, что идет перебор связанных объектов и их сохранение (метод xPDO::_saveRelatedObject()). Здесь я еще раз уточню, что сохраняем мы объект $user, для которого объект $user->Profile является связанным. И вот здесь-то и получается, что фактически объект $user->Profile сохранится раньше, чем объект $user. Почему? Потому что в вызове $user->_saveRelatedObject($user->Profile) будет вызван метод $user->Profile->save(), а так как в текущий момент для $user->Profile нет связанных объектов, то он будет записан в базу данных. И что у нас здесь получается? $user->Profile уже сохранен и у него есть свой id, но id нет у объекта $user (потому что он еще не был сохранен). По этой причине и вторичный ключ $user->Profile->internalKey все еще пустой.

    ОК, с этим разобрались, едем дальше. А дальше у нас идет сохранение уже самого объекта $user с записью его в БД и присвоением ему id. Все, запись сделана. Вот теперь у нас у обоих объектов есть эти id-шники, но все еще нет значения $user->Profile->internalKey. Вот как раз для этого и вызывается метод $user->_saveRelatedObjects() еще раз. Теперь, когда будет сохраняться связанный объект $user->Profile, он сможет получить значение $user->id и присвоить его в качестве $user->Profile->internalKey и сохраниться.

    Да, я согласен, что все это очень запутанно (а объясняю это еще запутанней), но логика во всем этом есть. И, собственно, именно по этой причине я вижу такое упорное использование MyIsam вместо innoDB. Почему? Да потому что на innoDB это просто не сможет полноценно работать. И вот как раз сейчас мы разберем имеющуюся проблему, а не сам принцип работы. Сразу скажу, что для полного понимания всего этого требуется хорошее понимание MySQL, а именно понимание транзакций, primary и foreign key и т.п.

    Давайте настроим нашу базу данных еще правильней, а именно настроим первичные и вторичные ключи на уровне самой базы. Для этого выполним следующее:

    1. Переведем таблицы на движок innoDB.





    2. В таблице modx_users поле id int(10)unsigned, а в modx_users_attributes поле internalKey int(10) (не unsigned). Из-за этого мы просто не сможем настроить вторичный ключ, ибо типы данных в колонках обеих таблиц обязаны полностью совпадать.
    Меняем на unsigned



    3 Создаем вторичный ключ





    Если при сохранении вторичного ключа вы не получили никаких ошибок, то замечательно! Но есть несколько ошибок, которые вы можете получить. Самые распространенные из них:
    1. Типы данных не совпадают.
    2. Для вторичной записи не существует первичной (то есть, к примеру, у вас есть запись в modx_user_attributes с internalKey = 5, а записи в modx_users с id = 5 нету).

    А теперь давайте посмотрим суть проблемы на примере. Для этого выполним в консоли следующий код:
    
    <?php
    
    $user_data = array(
        "username"  => "test_". rand(1,100000),
    );
    
    $profile_data = array(
        "email" => "test@local.host",
    );
    
    $user = $modx->newObject('modUser', $user_data);
    $user->Profile = $modx->newObject('modUserProfile', $profile_data);
    
    $user->save();
    
    print '<pre>';
    print_r($user->toArray());
    print_r($user->Profile->toArray());
    


    Сейчас мы никакой проблемы не увидели, все сохранилось без замечаний.
    Примерный вывод при успешном выполнении
    
    Array
    (
        [id] => 59
        [username] => test_65309
        [password] => 
        [cachepwd] => 
        [class_key] => modUser
        [active] => 1
        [remote_key] => 
        [remote_data] => 
        [hash_class] => hashing.modPBKDF2
        [salt] => 
        [primary_group] => 0
        [session_stale] => 
        [sudo] => 
    )
    Array
    (
        [id] => 54
        [internalKey] => 59
        [fullname] => 
        [email] => test@local.host
        [phone] => 
        [mobilephone] => 
        [blocked] => 
        [blockeduntil] => 0
        [blockedafter] => 0
        [logincount] => 0
        [lastlogin] => 0
        [thislogin] => 0
        [failedlogincount] => 0
        [sessionid] => 
        [dob] => 0
        [gender] => 0
        [address] => 
        [country] => 
        [city] => 
        [state] => 
        [zip] => 
        [fax] => 
        [photo] => 
        [comment] => 
        [website] => 
        [extended] => 
    )
    



    А теперь немного изменим наш код:
    
    <?php
    
    $user_data = array(
        "username"  => "test_". rand(1,100000),
    );
    
    $profile_data = array(
        "email" => "test@local.host",
    );
    
    $user = $modx->newObject('modUser', $user_data);
    $user->Profile = $modx->newObject('modUserProfile', $profile_data);
    
    // Заранее установим id первичному объекту. Здесь следует указать свой какой-нибудь id, убедившись, что в БД он не занят.
    $user->id = 40;
    
    $user->save();
    
    print '<pre>';
    print_r($user->toArray());
    print_r($user->Profile->toArray());
    


    Что мы теперь получим при выполнении этого кода?

    1. Сообщение об SQL-ошибке
    
    Array
    (
        [0] => 23000
        [1] => 1452
        [2] => Cannot add or update a child row: a foreign key constraint fails (`shopmodxbox_test2`.`modx_user_attributes`, CONSTRAINT `modx_user_attributes_ibfk_1` FOREIGN KEY (`internalKey`) REFERENCES `modx_users` (`id`))
    )
    


    2. Оба наши объекта все-таки сохранились и имеют корректные id и internalKey.

    Почему так происходит? При сохранении вторичного объекта xPDO проверяет имеется ли значение первичного ключа, и только если он есть, тогда уже устанавливает его значение в качестве вторичного ключа и сохраняет этот объект. В нашем случае мы вручную указали первичный ключ id и вторичный объект сумел получить его значение и попытался записаться в базу данных, но так как фактически первичной записи там нет, мы и получаем SQL-ошибку о невозможности записать вторичную запись без первичного объекта. Но сохранение первичного объекта на этом не прерывается. После этого первичный объект $user успешно записывается в базу, а при повторной попытке сохранения связанного объекта $user->Profile уже нормально все сохраняется, так как первичная запись имеется.

    Из всего этого вытекает два заключения.

    1. При сохранении связанных объектов невозможно отследить ошибки сохранения вторичных объектов и как-то на них среагировать. То есть никогда нельзя с уверенностью сказать, по какой причине не был сохранен вторичный объект (то ли нет пока первичного объекта, и он сможет позже записаться при повторном вызове метода xPDOObject::_saveRelatedObjects(), то ли там какой-нибудь уникальный ключ сконфликтовал и запись в принципе не может быть записана, то ли там валидация на уровне мапы не прошла и т.д. и т.п.).

    2. По этой причине невозможно использовать полноценно транзакции.

    Возможный путь решения этой проблемы.

    Мы видим решение этой проблемы в том, чтобы разграничить первый и второй вызов метода xPDOObject::_saveRelatedObjects() по типам связанных объектов, а именно первый вызов — для первичных объектов, а второй вызов — для вторичных. В таком случае точно не будет путаницы с ключами, и если объект по какой-то причине не сохранился, то это точно будет означать ошибку и можно будет выполнять прерывание процесса сохранения (в том числе и откат транзакций).
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое