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

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

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

Итак, одна из этих серьезных проблем связана именно с методом xPDOObject: save () (вызываемая при сохранении xPDO-объектов). Суть этой проблемы в том, что внутри него срабатывает метод сохранения связанных объектов xPDOObject::_saveRelatedObjects () дважды. Раз и два. Делается это для того, чтобы выставить первичные и вторичные ключи для этих связанных объектов (см. справочный материал от Ильи Уткина). Объясню подробней на примере. Вот код:

 "test",
);

$profile_data = array();

$user = $modx->newObject('modUser', $user_data);
$user->Profile = $modx->newObject('modUserProfile', $profile_data);
$user->save();

print '
';
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.
257fbd83f3b949c58afe730cfa7435e5.jpg

9f797868368c497b8efb4dd8b934c916.jpg

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

Меняем на unsigned
1583f346aff74ad29d493584e4928078.jpg
3 Создаем вторичный ключ
0c356e89b04c445d912740937568f018.jpg

afeb244afc84482c9cfd8fe50b0277f2.jpg

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

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


 "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 '
';
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] => 
)


А теперь немного изменим наш код:


 "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 '
';
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 () по типам связанных объектов, а именно первый вызов — для первичных объектов, а второй вызов — для вторичных. В таком случае точно не будет путаницы с ключами, и если объект по какой-то причине не сохранился, то это точно будет означать ошибку и можно будет выполнять прерывание процесса сохранения (в том числе и откат транзакций).

© Habrahabr.ru