PHP: полный мануал с примерами, функциями и ООП
Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Дерево руководства
Глава 300 · После подключения PDO_ODBC и чтения результатов
PDO_ODBC: целый заказ, квитанция процедуры и завершение результатов
Сохранить связанные записи целиком, получить полезные результаты процедуры и продолжить работу после контролируемой ошибки.
От целого заказа к готовой квитанции
Импорт заказа должен сохранить и шапку, и все позиции. Процедура должна отдать именно ту квитанцию, которую ждёт PHP. После ошибки соединение нужно привести в понятное состояние. Эти задачи требуют различать три вещи: подтверждение изменений, получение результатов и освобождение ресурсов.
- Таблицы: ORDER 101 связан с позициями CAB 2 и CLP 3 через order_id
- Транзакция: все записи подтверждаются вместе либо отменяются
- Результат: SELECT отдаёт нужные строки квитанции
- Курсор: закончить чтение и освободить перед следующей работой
PDO связывает PHP-приложение с базой: объект соединения создаёт команды, а PDOStatement позволяет получить их результат. PDO_ODBC передаёт работу менеджеру ODBC и выбранному драйверу базы. Поэтому одинаковые методы PDO не делают SQL, типы, курсоры и ошибки всех серверов одинаковыми.
Самостоятельные проекты используют PDO_ODBC из PHP 8.5.11 для Windows, Microsoft ODBC Driver 18 и отдельный SQL Server LocalDB. Это конкретный проверяемый маршрут; другие серверы требуют своего драйвера и проверки. Начало SQL и PDO объясняет таблицу, запрос и получение одной строки, а глава подключения PDO_ODBC — слои маршрута и подготовку окружения.
Здесь предполагаются подключение и параметры PDO_ODBC и чтение ограниченного результата. $db — открытый объект соединения, $statement — команда с её текущим результатом. Транзакция принадлежит соединению; closeCursor вызывается у команды и не заменяет commit или rollBack.
Связанные таблицы и границы транзакции
Одна строка заказа содержит номер и внешний код клиента. Другая таблица содержит позиции с order_id. Внешний ключ разрешает позиции только существующего заказа, а CHECK qty > 0 запрещает нулевое количество. Но сами ограничения не заставляют три отдельных INSERT стать одной операцией: для этого нужны границы транзакции.
- beginTransaction → начало одной операции
- INSERT шапки 101 → INSERT позиции CAB 2
- Ошибка второй позиции CLP 0 → rollBack
- Новое чтение: нет ни шапки 101, ни первой позиции
$db->beginTransaction();
// Здесь выполняются изменения одной задачи.
$db->rollBack();Это минимальная форма: без INSERT между вызовами она ничего не меняет в таблицах. Скобки без аргументов означают, что параметры не нужны. beginTransaction возвращает bool и при уже начатой транзакции или отсутствии поддержки может выбросить PDOException. commit подтверждает изменения; rollBack отменяет неподтверждённые. После успешного commit отменять эту транзакцию поздно. Полный пример ниже проверяет таблицы повторным SELECT после обоих исходов.
| Метод или настройка | Практический договор | Что не следует предполагать |
|---|---|---|
| beginTransaction / inTransaction | Начать явную транзакцию и проверить состояние PDO | Второй beginTransaction не является независимой вложенной транзакцией |
| commit / rollBack | Закончить собственную транзакцию; проверять результат и ошибки | Исключение из execute само по себе не вызывает ваш rollBack |
| PDO::ATTR_AUTOCOMMIT | PDO_ODBC умеет менять режим соединения; примеры оставляют исходный режим включённым | Отключать атрибут и смешивать его с ручными BEGIN/COMMIT без договора |
| closeCursor | Освободить результат команды | Принять освобождение курсора за отмену изменения |
| finally | Выполнить освобождение при нормальном выходе и при исключении | Гарантировать выполнение PHP finally после аварийного завершения процесса |
В исходнике PDO_ODBC beginTransaction выключает ODBC autocommit для явной транзакции; commit/rollBack обращаются к SQLEndTran. При обычном исходном режиме autocommit восстанавливается после завершения. inTransaction отражает отслеживаемую PDO транзакцию и не является универсальной проверкой произвольного SQL BEGIN из другой части программы. Не меняйте ATTR_AUTOCOMMIT посреди уже начатой транзакции.
Схему учебного примера создают до импорта. DDL и транзакционные возможности различаются между СУБД: поведение SQL Server не доказывает аналогичный откат CREATE TABLE в MySQL или другом ODBC-источнике. Общие принципы транзакций объясняют атомарность, блокировки и повтор операций подробнее.
Рабочая обработка ошибок: сохранить исходную диагностику, отменить ещё активную собственную транзакцию, освободить statement и сообщить понятный результат. Ошибка отката должна фиксироваться отдельно, чтобы не потерять причину исходного отказа. При обрыве сети нельзя считать, что COMMIT точно не произошёл: внешнему повтору нужен уникальный ключ операции и сверка состояния. Наш пример воспроизводит нарушение CHECK в живом соединении; сетевой обрыв и гонку двух покупателей он не моделирует.
Сохранить заказ и позиции целиком, затем выгрузить квитанцию
Интеграция принимает заказ WEB-178 с двумя позициями. Первая поставка содержит CLP с количеством 0: шапка заказа и уже вставленная CAB должны исчезнуть вместе. После исправления до 3 сохраняется целый заказ, а JOIN отдаёт только его строки в CSV.
- ORDER 101 → LINE(CAB 2) → LINE(CLP 0)
- CHECK отклоняет CLP 0 → явный rollBack
- Новый SELECT: обе таблицы пусты
- Исправленный CLP 3 → commit → JOIN → две строки import-receipt.csv
Минимальный вызов
$db->beginTransaction();
try {
$header->execute([101, 'WEB-178']);
$line->execute([101, 'CAB', 2]);
$header->closeCursor(); $line->closeCursor();
$db->commit();
} catch (Throwable $error) {
if ($db->inTransaction()) { $db->rollBack(); }
throw $error;
}В коротком фрагменте header и line — уже подготовленные INSERT. try содержит одну операцию; catch получает её ошибку. throw передаёт исходный отказ вызывающему коду. Полная программа создаёт обе таблицы, намеренно нарушает CHECK второй строкой и затем читает базу после отката. Вторая попытка содержит исправленные данные, а не автоматический сетевой повтор.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| После ошибочного импорта | 0 заказов; 0 позиций |
| Причина отказа | CHECK: количество должно быть больше 0 |
| Сохранённый заказ | 101 / WEB-178 |
| Позиции | CAB: 2; CLP: 3 |
| Транзакция после подтверждения | завершена |
| Файл | import-receipt.csv |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
$orders=p178OwnTable($db,$owned,'orders','(id int PRIMARY KEY,client_ref varchar(30) NOT NULL UNIQUE)');
$orderTable=p178Qualified($orders);
$lines=p178OwnTable($db,$owned,'order_lines','(order_id int NOT NULL,sku varchar(12) NOT NULL,qty int NOT NULL CHECK(qty>0),PRIMARY KEY(order_id,sku),FOREIGN KEY(order_id) REFERENCES '.$orderTable.'(id))');
$lineTable=p178Qualified($lines);$bad=null;$activeAfterFailure=false;
$beforeAuto=$db->getAttribute(PDO::ATTR_AUTOCOMMIT);
$db->beginTransaction();
try {
p178Command($db,'INSERT INTO '.$orderTable.'(id,client_ref) VALUES (?,?)',[101,'WEB-178']);
p178Command($db,'INSERT INTO '.$lineTable.'(order_id,sku,qty) VALUES (?,?,?)',[101,'CAB',2]);
p178Command($db,'INSERT INTO '.$lineTable.'(order_id,sku,qty) VALUES (?,?,?)',[101,'CLP',0]);
} catch (PDOException $e) {
$bad=['state'=>$e->errorInfo[0]??(string)$e->getCode(),'code'=>$e->errorInfo[1]??null];
$activeAfterFailure=$db->inTransaction();$db->rollBack();
}
if ($bad===null) {throw new RuntimeException('Нулевая позиция неожиданно принята.');}
$emptyOrders=(int)p178Read($db,'SELECT COUNT(*) AS n FROM '.$orderTable)[0]['n'];
$emptyLines=(int)p178Read($db,'SELECT COUNT(*) AS n FROM '.$lineTable)[0]['n'];
$afterRollback=$db->inTransaction();
$db->beginTransaction();
try {
p178Command($db,'INSERT INTO '.$orderTable.'(id,client_ref) VALUES (?,?)',[101,'WEB-178']);
$insert=$db->prepare('INSERT INTO '.$lineTable.'(order_id,sku,qty) VALUES (?,?,?)');
foreach ([['CAB',2],['CLP',3]] as [$sku,$qty]) {$insert->execute([101,$sku,$qty]);$insert->closeCursor();}
$insert=null;$db->commit();
} catch (Throwable $e) {if ($db->inTransaction()) {$db->rollBack();}throw $e;}
$selected=p178Read($db,'SELECT o.id,o.client_ref,l.sku,l.qty FROM '.$orderTable.' o JOIN '.$lineTable.' l ON l.order_id=o.id WHERE o.id=? ORDER BY l.sku',[101]);
$items=array_map(static fn(array $r):array=>['id'=>(int)$r['id'],'client_ref'=>$r['client_ref'],'sku'=>$r['sku'],'qty'=>(int)$r['qty']],$selected);
$csv="order_id;client_ref;sku;qty\n";foreach($items as $r){$csv.=$r['id'].';'.$r['client_ref'].';'.$r['sku'].';'.$r['qty']."\n";}
$written=file_put_contents(__DIR__.'/import-receipt.csv',$csv);
$checks=['first_constraint_failed'=>$bad['state']==='23000'&&(int)$bad['code']===547,
'transaction_still_active_at_failure'=>$activeAfterFailure,'rollback_removed_header'=>$emptyOrders===0,
'rollback_removed_first_line'=>$emptyLines===0,'rollback_closed_transaction'=>$afterRollback===false,
'committed_join_matches'=>$items===[['id'=>101,'client_ref'=>'WEB-178','sku'=>'CAB','qty'=>2],['id'=>101,'client_ref'=>'WEB-178','sku'=>'CLP','qty'=>3]],
'commit_closed_transaction'=>!$db->inTransaction(),'autocommit_preserved'=>$beforeAuto===true&&$db->getAttribute(PDO::ATTR_AUTOCOMMIT)===true,
'csv_saved'=>$written===strlen($csv)];
$rows=[['После ошибочного импорта','0 заказов; 0 позиций'],['Причина отказа','CHECK: количество должно быть больше 0'],
['Сохранённый заказ','101 / WEB-178'],['Позиции','CAB: 2; CLP: 3'],['Транзакция после подтверждения','завершена'],['Файл','import-receipt.csv']];
$observations=['failed_import'=>$bad,'active_at_failure'=>$activeAfterFailure,'counts_after_rollback'=>[$emptyOrders,$emptyLines],
'committed_rows'=>$items,'autocommit_before'=>$beforeAuto,'autocommit_after'=>$db->getAttribute(PDO::ATTR_AUTOCOMMIT)];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-import-transaction','Сохранить заказ и позиции целиком, затем выгрузить квитанцию',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
После ошибочного импорта — 0 заказов; 0 позиций
Причина отказа — CHECK: количество должно быть больше 0
Сохранённый заказ — 101 / WEB-178
Позиции — CAB: 2; CLP: 3
Транзакция после подтверждения — завершена
Файл — import-receipt.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать квитанцию связанного заказа
Границы решения
Внешний ключ защищает связь, а транзакция — совместное сохранение. Тест выполняется в одной сессии и не моделирует конкурентных покупателей или обрыв COMMIT. В рабочем импорте внешний client_ref служит частью идемпотентного договора. CSV здесь состоит из контролируемых кодов без разделителей; для произвольных полей используйте fputcsv.
Попробуйте изменить условие
Что будет, если вместо rollBack оставить catch пустым и затем вызвать commit?
Разбор
При доступной для продолжения транзакции может сохраниться шапка и первая позиция без второй. Исключение не означает, что все предыдущие успешные команды отменены. Здесь именно явный rollback и повторный SELECT подтверждают отсутствие обеих частей.
Вход, выход и наборы процедуры
Хранимая процедура — программа внутри базы. Для выдачи квитанции она может нормализовать входной код, прочитать строки и вернуть итог. Строки SELECT, параметр OUTPUT/INOUT и целочисленный RETURN-код — разные каналы. fetch не читает автоматически все эти каналы.
- PHP variable code=" order-178 " → параметр процедуры
- Сервер нормализует код до ORDER-178
- SELECT позиций → следующий SELECT квитанции
- Привязанная переменная INOUT и строка SELECT требуют отдельного чтения
$code = " order-178 ";
$statement = $db->prepare("{CALL dbo.ORDER_RECEIPT(?)}");
$statement->bindParam(1, $code, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 40);
$statement->execute();Это фрагмент для заранее созданной процедуры, а не команда создания ORDER_RECEIPT. Синтаксис {CALL …} — ODBC escape для вызова процедуры. Число 1 указывает первую позицию ?, $code передаётся по ссылке, | объединяет флаги. Число 40 выделяет выходной буфер для согласованного строкового параметра. Это не правило бизнес-валидации длины. bindValue передаёт входное значение, поэтому не заменяет bindParam для получения OUT.
| Вход/выход PDO_ODBC | Как задаётся | Граница |
|---|---|---|
| INPUT | Обычный bindParam с длиной 0 или bindValue | Тип и допустимая длина SQL-параметра задаются схемой/процедурой |
| OUTPUT | Положительная длина bindParam без PARAM_INPUT_OUTPUT | Нельзя добавлять длину обычному входу только ради ограничения текста |
| INOUT | Флаг PARAM_INPUT_OUTPUT и достаточный буфер | PDO_ODBC может преобразовать привязанную переменную в строку |
| LOB OUTPUT | Не поддерживается этим драйвером | Передавайте файл отдельно или возвращайте согласованный набор строк |
| Несколько SELECT | columnCount, fetch, nextRowset | Каждый набор может иметь другую схему; пустой набор не равен концу всех результатов |
Документы ODBC предупреждают, что выходные значения могут быть готовы только после завершения всех результатов. При этом PHP-драйвер имеет собственные моменты копирования буфера в переменную. В нашем профиле OUT процедуры без наборов строк доступен после execute. При двух SELECT привязанные переменные остались исходными даже после чтения и closeCursor; правильные значения пришли явной итоговой строкой. Полный проект ниже показывает оба исхода и сохраняет квитанцию из SELECT в receipt.json.
Есть ещё граница этого профиля: columnCount после процедуры вообще без наборов строк дал недостоверное число. По нему нельзя решать, что fetch разрешён. Если договор процедуры предусматривает только OUT, не вызывайте fetch. Это отличается от обычного SQL-пакета ниже, где UPDATE находится между корректными SELECT и его результат имеет 0 колонок.
SET NOCOUNT ON убирает сообщения о количестве затронутых строк из цепочки процедуры, но оставляет SELECT. Не добавляйте диагностический SELECT в процедуру с договорённостью о фиксированных наборах. Возвращайте явный код/состояние и полезные поля квитанции. Это также облегчает проверку, что выполнена именно нужная ветка.
Получить OUT и итоговую строку процедуры: два разных договора
Приложению нужна квитанция комплектации: нормализованный код заказа и сумма количества. Сначала процедура возвращает только OUT. Затем та же процедура получает ещё и список позиций. Проект показывает, почему после такого изменения нельзя продолжать считать привязанные переменные готовой квитанцией: надёжные данные здесь приходят отдельным SELECT.
- CALL без SELECT-строк → OUT: ORDER-178 и 5
- Добавили SELECT позиций → теперь два набора строк
- Привязанные OUT сохранили вход; отдельный SELECT вернул итог
- Проверенная итоговая строка + позиции → receipt.json
Минимальный вызов
$code = ' order-178 '; $qty = '0';
$call = $db->prepare('{CALL dbo.order_receipt(?,?)}');
$call->bindParam(1, $code, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 40);
$call->bindParam(2, $qty, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 20);
$call->execute();
echo $code, ' / ', $qty;Этот короткий фрагмент предполагает уже созданную процедуру БЕЗ наборов строк: результат ORDER-178 / 5. В полном проекте процедура и таблица создаются явно. bindParam связывает переменную по ссылке; INPUT_OUTPUT передаёт исходный текст и принимает новый, последний аргумент задаёт максимальную длину выходного буфера. qty передаётся строкой и приводится сервером к int; здесь это небольшое целое, а не пример точного BIGINT. После ALTER создаётся новый PDOStatement с новой формой результата.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| OUT без наборов строк | ORDER-178 / 5 |
| Первый набор строк | CAB: 2; CLP: 3 |
| Итоговая строка | ORDER-178 / 5 |
| OUT при наличии SELECT | остались исходными после чтения и закрытия |
| Файл | receipt.json |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
$items=p178OwnTable($db,$owned,'procedure_items','(sku varchar(12) PRIMARY KEY,qty int NOT NULL)');
$t=p178Qualified($items);p178Command($db,'INSERT INTO '.$t.' VALUES (?,?),(?,?)',['CAB',2,'CLP',3]);
$name=p178OwnName($db,$owned,'PROCEDURE','receipt');$proc=p178Qualified($name);
$definition=' @code varchar(40) OUTPUT,@qty int OUTPUT AS BEGIN SET NOCOUNT ON; SET @code=UPPER(LTRIM(RTRIM(@code))); SELECT @qty=SUM(qty) FROM '.$t.'; ';
p178Ddl($db,'CREATE PROCEDURE '.$proc.$definition.'END');
$code=' order-178 ';$qty='0';$s=$db->prepare('{CALL '.$proc.'(?,?)}');
$s->bindParam(1,$code,PDO::PARAM_STR|PDO::PARAM_INPUT_OUTPUT,40);
$s->bindParam(2,$qty,PDO::PARAM_STR|PDO::PARAM_INPUT_OUTPUT,20);
try {
$executed=$s->execute();$withoutRows=['code'=>$code,'total_qty'=>$qty];
// Этот вариант процедуры по определению не возвращает строк. fetch не вызывается.
} finally {p178Release($s);}
p178Ddl($db,'ALTER PROCEDURE '.$proc.$definition.'SELECT sku,qty FROM '.$t.' ORDER BY sku; SELECT @code AS receipt_code,@qty AS total_qty; END');
$code=' order-178 ';$qty='0';$s=$db->prepare('{CALL '.$proc.'(?,?)}');
$s->bindParam(1,$code,PDO::PARAM_STR|PDO::PARAM_INPUT_OUTPUT,40);
$s->bindParam(2,$qty,PDO::PARAM_STR|PDO::PARAM_INPUT_OUTPUT,20);
$sets=[];
try {
$s->execute();$atExecute=['code'=>$code,'total_qty'=>$qty];
do {$sets[]=p178Rows($s);} while($s->nextRowset());
$afterDrain=['code'=>$code,'total_qty'=>$qty];
} finally {p178Release($s);}
$afterClose=['code'=>$code,'total_qty'=>$qty];
if (count($sets)!==2 || count($sets[1])!==1 || !isset($sets[1][0]['receipt_code'],$sets[1][0]['total_qty'])) {
throw new RuntimeException('Обязательная итоговая строка процедуры не получена.');
}
$details=array_map(static fn(array $r):array=>['sku'=>$r['sku'],'qty'=>(int)$r['qty']],$sets[0]);
$receipt=['code'=>$sets[1][0]['receipt_code'],'total_qty'=>(int)$sets[1][0]['total_qty'],'items'=>$details];
$json=json_encode($receipt,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
$written=file_put_contents(__DIR__.'/receipt.json',$json);
$initial=['code'=>' order-178 ','total_qty'=>'0'];
$checks=['resultless_execute_succeeded'=>$executed===true,
'resultless_out_received'=>$withoutRows===['code'=>'ORDER-178','total_qty'=>'5'],
'details_received'=>$details===[['sku'=>'CAB','qty'=>2],['sku'=>'CLP','qty'=>3]],
'explicit_receipt_received'=>$receipt['code']==='ORDER-178'&&$receipt['total_qty']===5,
'two_select_results'=>count($sets)===2,'out_initial_after_execute'=>$atExecute===$initial,
'out_initial_after_drain'=>$afterDrain===$initial,'out_initial_after_close'=>$afterClose===$initial,
'receipt_file_saved'=>$written===strlen($json)];
$rows=[['OUT без наборов строк','ORDER-178 / 5'],['Первый набор строк','CAB: 2; CLP: 3'],
['Итоговая строка','ORDER-178 / 5'],['OUT при наличии SELECT','остались исходными после чтения и закрытия'],['Файл','receipt.json']];
$observations=['resultless_out'=>$withoutRows,'out_at_execute'=>$atExecute,'out_after_drain'=>$afterDrain,
'out_after_close'=>$afterClose,'selected_receipt'=>$receipt];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-procedure-receipt','Получить OUT и итоговую строку процедуры: два разных договора',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
OUT без наборов строк — ORDER-178 / 5
Первый набор строк — CAB: 2; CLP: 3
Итоговая строка — ORDER-178 / 5
OUT при наличии SELECT — остались исходными после чтения и закрытия
Файл — receipt.json
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать квитанцию процедуры
Границы решения
Это наблюдение для PDO_ODBC PHP 8.5.11 + ODBC 18 + SQL Server LocalDB, не универсальный договор всех PDO-драйверов. В варианте без наборов строк не используем columnCount как признак, что их можно читать: у проверенного драйвера он оказался недостоверным после такого вызова. Во втором варианте известны два SELECT, которые читаются последовательно. closeCursor здесь не обновил OUT. В рабочей системе изменение формы процедуры требует согласованного договора; ALTER в примере меняет только собственную учебную процедуру.
Попробуйте изменить условие
Процедура вернула позиции, но разработчик убрал последний SELECT с receipt_code. Можно ли отправить пользователю успешную квитанцию?
Разбор
Нет: программа проверяет наличие именно итоговой строки и отказывается формировать файл, если её нет. Получение первого набора или false от nextRowset ещё не подтверждает выполнение всего сценария. Для изменяющей данные процедуры дополнительно нужны транзакция, уникальный идентификатор операции и договор обработки неопределённого результата COMMIT.
Пустой набор, промежуточный результат и ошибка
У SQL-пакета могут подряд идти строки каталога, UPDATE без полей, пустой SELECT с полями и итоговая строка. Проверка if (!$rows) не отличит пустой SELECT от окончания всей команды. Нужно сначала узнать, есть ли поля у текущего результата, прочитать его, затем перейти к следующему.
do {
if ($statement->columnCount() > 0) {
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
// Обработать строку по схеме текущего набора.
}
}
} while ($statement->nextRowset());do выполняет тело хотя бы один раз для первого результата. Здесь команда начинается с обычного SELECT, у которого корректно описаны колонки. columnCount сообщает их число; fetch возвращает массив либо false при окончании строк. После последней строки SELECT могут существовать следующие наборы. nextRowset переключает их и возвращает bool. Для вывода в HTML значения экранируют, а большой результат не собирают целиком ради одного экрана.
| Состояние | Пример | Действие клиента |
|---|---|---|
| Есть поля и строки | sku/qty для CAB и CLP | Обработать полезные строки |
| Есть поля, строк нет | SELECT … WHERE id=-1 | Показать пустой результат по договору; продолжить цепочку |
| Нет полей | Промежуточный UPDATE | Не вызывать fetch как будто это таблица |
| Нет следующих результатов | nextRowset вернул false | Проверить договор результата и отдельные ошибки; не придумывать квитанцию |
| Отказ команды | Нарушение CHECK либо неверное выражение | Сохранить errorInfo именно ошибочного statement до следующего обращения |
В PHP 8.5.11 метод PDO_ODBC nextRowset возвращает false не только для SQL_NO_DATA: исходник не переводит все ошибки SQLMoreResults в отдельное PDOException. Поэтому один false не доказывает успешность каждого действия внутри произвольного SQL-пакета. Для критических изменений используйте явную транзакцию, проверку бизнес-итога и согласованную завершающую квитанцию; ошибки execute/fetch не игнорируйте.
PDO_ODBC closeCursor вызывает ODBC SQLCloseCursor. Это не общий цикл чтения всех наборов, который PHP использует для некоторых других драйверов. Метод оставляет statement пригодным к повторному execute, но не является подтверждением транзакции, не заменяет чтение нужных наборов и не удостоверяет получение OUT. Полный проект ниже сначала получает полезную цепочку результатов, затем сохраняет отдельный контролируемый отказ и показывает следующий успешный запрос.
Вопросы о пустом OUT при работающем INSERT, о переходе между ODBC и SQLSRV и об автоматическом откате после исключения используются как реальные постановки проблем. Ответы относятся к своим версиям; наши примеры написаны заново и проверяются на указанном драйвере.
Различить пустую выборку, UPDATE и потерянную итоговую строку
Отчёт склада выполняет несколько команд: показывает остатки, добавляет CAB, проверяет отсутствующий код и возвращает общий итог. Это не один массив. Проект отделяет четыре результата, а затем показывает отказ в середине другого пакета: первая строка уже получена, но обязательного завершения нет.
- SELECT CAB 2, CLP 3 → две строки
- UPDATE CAB+1 → команда без колонок
- SELECT MISSING → колонка есть, строк нет
- SELECT SUM → 6; отдельный сбой → нет обязательного завершения
Минимальный вызов
do {
if ($statement->columnCount() > 0) {
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
// Обрабатываем строку текущего SELECT.
}
}
} while ($statement->nextRowset());statement — уже выполненный пакет, первый результат которого имеет корректный описатель. do читает первый результат до перехода; nextRowset переключает на следующий. У UPDATE в этом проверенном пакете 0 колонок; у пустого SELECT колонка есть, а fetch сразу возвращает false. Пример не переносит это правило на процедурный вызов вообще без SELECT: его ограничение разобрано выше. После цикла отдельно проверяется обязательный итог; этот короткий цикл сам по себе не подтверждает успешность всех команд.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Цепочка результатов | 2 строки → UPDATE → 0 строк → итог 6 |
| Поздний RAISERROR | nextRowset: false; SQLSTATE: 00000; квитанции нет |
| Отдельный ошибочный SELECT | 42S22 / 207 |
| Чтение после отказа | CAB: 3; CLP: 3 |
| Файл | result-chain.json |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
$name=p178OwnTable($db,$owned,'result_chain','(sku varchar(12) PRIMARY KEY,qty int NOT NULL)');$t=p178Qualified($name);
p178Command($db,'INSERT INTO '.$t.' VALUES (?,?),(?,?)',['CAB',2,'CLP',3]);
$s=p178Request($db,'SELECT sku,qty FROM '.$t.' ORDER BY sku; UPDATE '.$t." SET qty=qty+1 WHERE sku='CAB'; SELECT sku FROM ".$t." WHERE sku='MISSING'; SELECT SUM(qty) AS total_qty FROM ".$t);
$sets=[];
try {
do {
$columns=$s->columnCount();$rowsHere=$columns>0?p178Rows($s):[];
$sets[]=['columns'=>$columns,'row_count'=>$s->rowCount(),'rows'=>$rowsHere];
} while($s->nextRowset());
$normalState=$s->errorInfo()[0];
} finally {p178Release($s);}
if(count($sets)!==4 || count($sets[3]['rows'])!==1 || !isset($sets[3]['rows'][0]['total_qty'])) {
throw new RuntimeException('Цепочка не содержит ожидаемую итоговую строку.');
}
$kinds=array_map(static fn(array $r):string=>$r['columns']===0?'command':($r['rows']===[]?'empty_rows':'rows'),$sets);
$total=(int)$sets[3]['rows'][0]['total_qty'];
$s=p178Request($db,"SELECT 1 AS before_error; RAISERROR('controlled178',16,1); SELECT 2 AS after_error");
try {
$before=p178Rows($s);$next=$s->nextRowset();$lateDiagnostic=$s->errorInfo();
$receiptMissing=!$next;
} finally {p178Release($s);}
$direct=p178Capture(static fn()=>p178Read($db,'SELECT missing_column_178 FROM '.$t));
$current=p178Read($db,'SELECT sku,qty FROM '.$t.' ORDER BY sku');
$current=array_map(static fn(array $r):array=>['sku'=>$r['sku'],'qty'=>(int)$r['qty']],$current);
$report=['sets'=>$kinds,'total_qty'=>$total,'late_receipt_missing'=>$receiptMissing,
'direct_error_state'=>$direct['state'],'current_items'=>$current];
$json=json_encode($report,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
$written=file_put_contents(__DIR__.'/result-chain.json',$json);
$checks=['four_distinct_results'=>count($sets)===4,
'shape_sequence'=>$kinds===['rows','command','empty_rows','rows'],
'first_result_has_two_items'=>array_column($sets[0]['rows'],'sku')===['CAB','CLP'],
'updated_total_is_six'=>$total===6,'normal_terminal_state'=>$normalState==='00000',
'late_first_row_received'=>$before===[['before_error'=>'1']],
'late_error_not_exposed'=>$next===false&&$lateDiagnostic[0]==='00000',
'missing_receipt_rejected'=>$receiptMissing===true,
'direct_error_exposed'=>$direct['failed']===true&&$direct['state']==='42S22'&&(int)$direct['code']===207,
'connection_read_after_failure'=>$current===[['sku'=>'CAB','qty'=>3],['sku'=>'CLP','qty'=>3]],
'report_saved'=>$written===strlen($json)];
$rows=[['Цепочка результатов','2 строки → UPDATE → 0 строк → итог 6'],
['Поздний RAISERROR','nextRowset: false; SQLSTATE: 00000; квитанции нет'],
['Отдельный ошибочный SELECT','42S22 / 207'],['Чтение после отказа','CAB: 3; CLP: 3'],['Файл','result-chain.json']];
$observations=['normal_sets'=>$sets,'normal_terminal_state'=>$normalState,'late_first_result'=>$before,
'late_next'=>$next,'late_diagnostics'=>$lateDiagnostic,'missing_final_receipt'=>$receiptMissing,
'direct_error'=>$direct,'healthy_rows'=>$current];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-result-recovery','Различить пустую выборку, UPDATE и потерянную итоговую строку',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Цепочка результатов — 2 строки → UPDATE → 0 строк → итог 6
Поздний RAISERROR — nextRowset: false; SQLSTATE: 00000; квитанции нет
Отдельный ошибочный SELECT — 42S22 / 207
Чтение после отказа — CAB: 3; CLP: 3
Файл — result-chain.json
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать разбор цепочки результатов
Границы решения
В проверенном PDO_ODBC 8.5.11 поздняя ошибка RAISERROR не появилась в errorInfo: SQLSTATE остался 00000. В исходнике nextRowset сводит часть ошибок SQLMoreResults к false. Поэтому нельзя считать false доказательством успешного пакета. Отдельная команда с неизвестной колонкой действительно дала исключение 42S22/207. Для критических записей лучше явные проверяемые шаги и транзакция; показанный нормальный UPDATE работает в autocommit, а следующий ошибочный пакет ничего не меняет. rowCount для SELECT не заменяет COUNT(*).
Попробуйте изменить условие
Если из первого пакета удалить финальный SELECT SUM, достаточно ли того, что UPDATE уже выполнился?
Разбор
Для квитанции — нет. Программа проверяет наличие последнего набора и поле total_qty; без него отчёт не считается завершённым. При autocommit выполненный UPDATE при этом уже сохранён. Проверка квитанции обнаруживает незавершённый договор, но не отменяет данные: атомарность задаётся отдельной транзакцией.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


