Особенности метода 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 и т.п.
Давайте настроим нашу базу данных еще правильней, а именно настроим первичные и вторичные ключи на уровне самой базы. Для этого выполним следующее:
2. В таблице modx_users поле id int (10)unsigned, а в modx_users_attributes поле internalKey int (10) (не unsigned). Из-за этого мы просто не сможем настроить вторичный ключ, ибо типы данных в колонках обеих таблиц обязаны полностью совпадать.
Если при сохранении вторичного ключа вы не получили никаких ошибок, то замечательно! Но есть несколько ошибок, которые вы можете получить. Самые распространенные из них:
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 () по типам связанных объектов, а именно первый вызов — для первичных объектов, а второй вызов — для вторичных. В таком случае точно не будет путаницы с ключами, и если объект по какой-то причине не сохранился, то это точно будет означать ошибку и можно будет выполнять прерывание процесса сохранения (в том числе и откат транзакций).