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

Дерево руководства
Глава 297 · После подключения, параметров и чтения PDO
PDO_SQLSRV: транзакции, снимки, процедуры и незаконченные результаты
Сохранить заказ с остатком, сравнить видимость двух сессий, получить OUTPUT и освободить соединение для следующего запроса.
Связать изменения и завершать результаты
Заказ должен появиться одновременно со списанием остатка. Отчёт должен читать данные по понятным правилам. После запроса соединение должно быть готово к следующему действию. Эти задачи связаны состоянием PDO-соединения: у него есть текущая транзакция, а у PDOStatement — ещё не законченные результаты.
- Соединение PDO: открыто и авторизовано
- Транзакция: несколько изменений до COMMIT/ROLLBACK
- PDOStatement: строки и следующие наборы результатов
- Освободить результат и закончить транзакцию — разные действия
PDO связывает PHP-приложение с базой: объект соединения создаёт команды, а PDOStatement позволяет получить их результат. PDO_SQLSRV реализует этот договор для SQL Server. Общий интерфейс сохраняется, но параметры подключения, типы, курсоры и границы результатов зависят от драйвера.
Проекты рассчитаны на отдельный SQL Server LocalDB. Проверяемый профиль: PHP 8.5.11 и PDO_SQLSRV 5.13.3. Для рабочего приложения нужно собственное подключение с необходимыми правами. Начало SQL и PDO объясняет таблицу, запрос и получение одной строки.
Сначала пройдите подключение и запрос с параметрами. Если таблица, первичный ключ и связь пока незнакомы, начните с первой базы через PDO. Здесь $db обозначает уже открытое соединение; -> вызывает метод этого объекта, а скобки передают аргументы.
beginTransaction, commit и rollBack: что меняется
В autocommit отдельная успешно выполненная команда подтверждается самостоятельно. Когда UPDATE остатка и INSERT заказа — одна задача, явно объедините их. beginTransaction() начинает работу, commit() подтверждает, rollBack() отменяет ещё не подтверждённое. После commit прежнюю транзакцию уже не откатить.
$db->beginTransaction();
// Изменения одной задачи.
$db->rollBack();Без изменений между границами отменять нечего: это минимальная форма вызова. Полный проект ниже проверяет обе таблицы после подтверждения и после намеренной ошибки. inTransaction() помогает не вызывать rollBack после уже завершённой транзакции. Повторный beginTransaction до завершения предыдущего не создаёт независимую вложенную транзакцию.
Для PDO_SQLSRV beginTransaction не управляется атрибутом PDO::ATTR_AUTOCOMMIT. Используйте методы PDO для границ, а не смесь ручных BEGIN/COMMIT и состояния драйвера. Курсоры следует завершить до начала новой транзакции. Ни beginTransaction, ни включённый MARS не гарантируют, что любой незаконченный запрос совместим с её началом.
| Вопрос | Что нужно проверить | Ошибка проектирования |
|---|---|---|
| Команда выполнилась, но товара не хватило | rowCount после условного UPDATE равен 1 | Принять ноль изменённых строк за успешный заказ |
| Вторая команда завершилась исключением | Откат и повторное чтение обеих таблиц | Оставить списанный остаток без заказа |
| Повторно пришёл тот же запрос клиента | Уникальный ключ операции и правила идемпотентности | Создать второй заказ после сетевого повтора |
| Нужен вызов внешнего API | Согласовать отдельную доставку события после COMMIT | Держать блокировки во время ожидания сети |
Первичный ключ однозначно называет строку. Внешний ключ product_id связывает заказ с существующим товаром; CHECK запрещает отрицательный остаток и неположительное количество. Ограничения защищают данные независимо от PHP. Транзакция отвечает за совместное изменение, а не заменяет эти ограничения. Общие принципы транзакций раскрывают атомарность и границы подробнее.
Заказ и остаток: подтвердить вместе или отменить вместе
Покупатель заказывает две детали из пяти. Нельзя списать детали и потерять заказ при ошибке второй команды. Проект создаёт связанные таблицы склада и заказов, сохраняет одну заявку и затем намеренно получает отказ ограничения CHECK во второй заявке. CSV показывает только подтверждённый заказ.
- stock: 5 деталей
- UPDATE с available>=2 → остаток 3
- INSERT заказа 101 → COMMIT
- Ошибочная заявка → ROLLBACK → остаток снова 3
Минимальный вызов
$db->beginTransaction();
try {
// UPDATE остатка и INSERT заказа.
$db->commit();
} catch (Throwable $error) {
if ($db->inTransaction()) { $db->rollBack(); }
throw $error;
}beginTransaction открывает транзакцию этого PDO-соединения. try содержит согласованную работу; catch получает исключение. inTransaction проверяет, нужна ли ещё отмена. throw передаёт ошибку вызывающему коду. Комментарий в минимальной схеме заменён настоящими UPDATE и INSERT в полном проекте. rowCount после UPDATE проверяет число изменённых строк; ноль не равен исключению.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Подтверждённый заказ | 101 |
| Зарезервировано | 2 |
| Остаток после COMMIT | 3 |
| Остаток после ошибки и ROLLBACK | 3 |
| Изменено при запросе 4 из 3 | 0 |
| Заказов в базе | 1 |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p177Config();$db=p177Connect($config);$runtime=p177Runtime($db);
$stock=p177Qualified(p177OwnTable($db,$owned,'stock','(id int PRIMARY KEY, available int NOT NULL CHECK(available>=0))'));
$orders=p177Qualified(p177OwnTable($db,$owned,'orders','(id int PRIMARY KEY, product_id int NOT NULL REFERENCES '.$stock.'(id), quantity int NOT NULL CHECK(quantity>0))'));
p177Command($db,"INSERT INTO $stock(id,available) VALUES(1,5)");
p177Begin($db);
try {
$affected=p177Command($db,"UPDATE $stock SET available=available-? WHERE id=? AND available>=?",[2,1,2]);
if ($affected!==1) {throw new RuntimeException('Недостаточно товара.');}
p177Command($db,"INSERT INTO $orders(id,product_id,quantity) VALUES(?,?,?)",[101,1,2]);
p177Commit($db);
} catch (Throwable $e) {p177Rollback($db);throw $e;}
$afterCommit=(int)p177Read($db,"SELECT available FROM $stock WHERE id=1")[0]['available'];
p177Begin($db);
try {
p177Command($db,"UPDATE $stock SET available=available-1 WHERE id=1");
$bad=p177Capture(fn()=>p177Command($db,"INSERT INTO $orders(id,product_id,quantity) VALUES(102,1,0)"));
if (!$bad['failed']) {throw new RuntimeException('Ожидался отказ CHECK для нулевого количества.');}
} finally {p177Rollback($db);}
$afterRollback=(int)p177Read($db,"SELECT available FROM $stock WHERE id=1")[0]['available'];
p177Begin($db);
try {
$insufficient=p177Command($db,"UPDATE $stock SET available=available-? WHERE id=? AND available>=?",[4,1,4]);
} finally {p177Rollback($db);}
$saved=p177Read($db,"SELECT id,product_id,quantity FROM $orders ORDER BY id");
$csv="order_id,product_id,quantity,remaining\n".$saved[0]['id'].','.$saved[0]['product_id'].','.$saved[0]['quantity'].','.$afterRollback."\n";
file_put_contents(__DIR__.'/reservation.csv',$csv);
$checks=['one_product_changed'=>$affected===1,'commit_stock'=>$afterCommit===3,
'constraint_rejected'=>$bad['failed']&&$bad['state']==='23000',
'rollback_restored_stock'=>$afterRollback===3,'failed_order_absent'=>count($saved)===1&&(int)$saved[0]['id']===101,
'shortage_changed_nothing'=>$insufficient===0,'order_quantity'=>(int)$saved[0]['quantity']===2,
'transaction_finished'=>!$db->inTransaction()];
$rows=[['Подтверждённый заказ',101],['Зарезервировано',2],['Остаток после COMMIT',$afterCommit],
['Остаток после ошибки и ROLLBACK',$afterRollback],['Изменено при запросе 4 из 3',$insufficient],['Заказов в базе',count($saved)]];
$observations=['orders'=>$saved,'rejected_insert'=>$bad,'csv'=>$csv];
} finally {
if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}
p177Report('pdo177-reservation','Заказ и остаток: подтвердить вместе или отменить вместе',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Подтверждённый заказ — 101
Зарезервировано — 2
Остаток после COMMIT — 3
Остаток после ошибки и ROLLBACK — 3
Изменено при запросе 4 из 3 — 0
Заказов в базе — 1
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать подтверждённый заказ CSV
Границы решения
Условие остатка находится в UPDATE, поэтому приложение не полагается на устаревшее предварительное чтение. CHECK защищает данные и при другом клиенте. Один сценарий не является нагрузочным тестом конкурентных покупателей. Для повторов HTTP-запроса нужен отдельный уникальный ключ идемпотентности. Отправку писем и запрос к платёжному сервису нельзя откатить методом PDO.
Попробуйте изменить условие
После успешного заказа попробуйте запросить четыре детали. Нужно ли создавать ещё один заказ, если UPDATE изменил ноль строк?
Разбор
Нет. Условие available>=4 ложно при остатке 3. Обработчик отменяет незавершённую работу и сообщает о нехватке. Нельзя заменять проверку rowCount выводом «успешно», потому что корректно исполненная SQL-команда могла не изменить ни одной строки.
Пять уровней изоляции и настройки снимка
Изоляция задаёт, какие изменения других транзакций видны и какие ограничения мешают конфликтующим действиям. Аналогия — несколько редакций складского листа: один отчёт читает выбранную редакцию, другой экран обращается к свежей. Это модель видимости, а не создание полной копии базы на каждый beginTransaction.
| DSN / константа PDO | Смысл | Граница применения |
|---|---|---|
| TransactionIsolation=READ_UNCOMMITTED / SQLSRV_TXN_READ_UNCOMMITTED | Можно прочитать ещё не подтверждённые изменения | Грязное чтение не подходит для точного остатка и финансового итога |
| READ_COMMITTED / SQLSRV_TXN_READ_COMMITTED | Не читать неподтверждённые изменения; следующее чтение может увидеть новое | При READ_COMMITTED_SNAPSHOT=ON используются версии на уровне команды; это отдельная настройка |
| REPEATABLE_READ / SQLSRV_TXN_REPEATABLE_READ | Защитить прочитанные строки от изменения до завершения | Новые строки, подходящие условию, всё ещё могут появляться |
| SERIALIZABLE / SQLSRV_TXN_SERIALIZABLE | Также защищать диапазоны прочитанного условия | Более строгий доступ может увеличить ожидания и снизить параллелизм |
| SNAPSHOT / SQLSRV_TXN_SNAPSHOT | Согласованный снимок транзакции через версии строк | Нужен ALLOW_SNAPSHOT_ISOLATION; возможен конфликт при обновлении |
Полный справочник DSN показывает синтаксис. В закреплённом драйвере 5.13.3 константы SQLSRV_TXN_* — строки, несмотря на подпись int в PHP.net. Не придумывайте числовые значения. Пример передаёт константу непосредственно в TransactionIsolation.
Подготовка отдельной учебной базы включает ALLOW_SNAPSHOT_ISOLATION ON один раз. Сценарий не переключает производственную базу. READ_COMMITTED_SNAPSHOT остаётся выключенным и фиксируется в результате: без этого было бы непонятно, какой именно READ COMMITTED мы наблюдаем. Администратор оценивает хранение версий и активные сессии перед сменой настроек.
Конфликт обновления при SNAPSHOT и взаимоблокировка — не повод бесконечно повторять последний UPDATE. Нужно завершить неудачную транзакцию и решить, допустим ли ограниченный повтор всей операции с тем же ключом идемпотентности. Пример ниже исследует чтение двух сессий; он не заявляет, что воспроизвёл все конфликты и все пять уровней.
Два чтения отчёта: свежие данные или один снимок
Отчёт делает несколько связанных чтений. Пока он работает, другой пользователь изменяет сумму с 10 на 20. Два настоящих PDO-соединения выполняют действия по очереди: читатель, запись с подтверждением, повторное чтение. В CSV видно, почему один отчёт может получить разные значения или сохранить прежний снимок.
- Читатель прочитал 10
- Другой PDO записал и подтвердил 20
- READ COMMITTED → 20; SNAPSHOT → прежние 10
- Завершили SNAPSHOT → следующее чтение 20
Минимальный вызов
$reader = new PDO($dsn . ';TransactionIsolation=SNAPSHOT', $user, $password);
$reader->beginTransaction();
// Несколько чтений одного отчёта.
$reader->commit();TransactionIsolation — строковая опция DSN. Она выбирается до транзакции. $reader — отдельное соединение: commit другого объекта его снимок не завершает. В проекте именованную строку задаёт константа PDO::SQLSRV_TXN_SNAPSHOT. Для этого режима администратор заранее включает ALLOW_SNAPSHOT_ISOLATION в собственной базе. Программа урока только читает состояние настройки и не меняет сервер.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| READ COMMITTED: первое чтение | 10 |
| READ COMMITTED: после чужого COMMIT | 20 |
| SNAPSHOT: первое чтение | 10 |
| SNAPSHOT: после чужого COMMIT | 10 |
| После завершения SNAPSHOT | 20 |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
$reader=null;
try {
$config=p177Config();$db=p177Connect($config);$runtime=p177Runtime($db);
$table=p177Qualified(p177OwnTable($db,$owned,'visibility','(id int PRIMARY KEY, amount int NOT NULL)'));
p177Command($db,"INSERT INTO $table(id,amount) VALUES(1,10)");
$settings=p177Read($db,'SELECT snapshot_isolation_state,is_read_committed_snapshot_on FROM sys.databases WHERE name=DB_NAME()')[0];
$readValue=static fn(PDO $connection):int=>(int)p177Read($connection,"SELECT amount FROM $table WHERE id=1")[0]['amount'];
$reader=p177Connect($config,['TransactionIsolation'=>PDO::SQLSRV_TXN_READ_COMMITTED]);
p177Begin($reader);
$rcBefore=$readValue($reader);
p177Command($db,"UPDATE $table SET amount=20 WHERE id=1");
$rcAfter=$readValue($reader);
p177Commit($reader);$rcNext=$readValue($reader);p177Close($reader);
p177Command($db,"UPDATE $table SET amount=10 WHERE id=1");
$reader=p177Connect($config,['TransactionIsolation'=>PDO::SQLSRV_TXN_SNAPSHOT]);
p177Begin($reader);
$snapshotBefore=$readValue($reader);
p177Command($db,"UPDATE $table SET amount=20 WHERE id=1");
$snapshotAfter=$readValue($reader);
p177Commit($reader);
$next=$readValue($reader);p177Close($reader);
$csv="mode,first_read,after_other_commit,next_transaction\nREAD_COMMITTED,$rcBefore,$rcAfter,$rcNext\nSNAPSHOT,$snapshotBefore,$snapshotAfter,$next\n";
file_put_contents(__DIR__.'/visibility.csv',$csv);
$checks=['snapshot_enabled'=>(int)$settings['snapshot_isolation_state']===1,
'rcsi_not_enabled'=>(int)$settings['is_read_committed_snapshot_on']===0,
'read_committed_sees_new'=>$rcBefore===10&&$rcAfter===20&&$rcNext===20,
'snapshot_keeps_value'=>$snapshotBefore===10&&$snapshotAfter===10,
'new_transaction_sees_twenty'=>$next===20,'writer_committed'=>$readValue($db)===20,
'reader_closed'=>$reader===null,'writer_not_in_transaction'=>!$db->inTransaction()];
$rows=[['READ COMMITTED: первое чтение',$rcBefore],['READ COMMITTED: после чужого COMMIT',$rcAfter],
['SNAPSHOT: первое чтение',$snapshotBefore],['SNAPSHOT: после чужого COMMIT',$snapshotAfter],
['После завершения SNAPSHOT',$next]];
$observations=['database_options'=>$settings,'values'=>[$rcBefore,$rcAfter,$snapshotBefore,$snapshotAfter,$next],'csv'=>$csv];
} finally {
p177Close($reader);
if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}
p177Report('pdo177-snapshot-visibility','Два чтения отчёта: свежие данные или один снимок',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
READ COMMITTED: первое чтение — 10
READ COMMITTED: после чужого COMMIT — 20
SNAPSHOT: первое чтение — 10
SNAPSHOT: после чужого COMMIT — 10
После завершения SNAPSHOT — 20
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать сравнение чтений CSV
Границы решения
Первое чтение закрепляет снимок. ALLOW_SNAPSHOT_ISOLATION и READ_COMMITTED_SNAPSHOT — разные параметры базы; здесь второй выключен. Это воспроизводимая последовательность двух сессий, а не тест одновременных потоков. SNAPSHOT не запрещает запись и не отменяет конфликты обновлений; при конфликте повторяют всю допустимую бизнес-транзакцию, а не случайную последнюю команду. Длинные снимки требуют хранения версий.
Попробуйте изменить условие
Почему обновлённые 20 видны после commit читателя, хотя запись была подтверждена раньше?
Разбор
Предыдущая явная SNAPSHOT-транзакция закончилась. Новое чтение относится уже к новой транзакции и видит подтверждённое изменение. Снимок не является вечным кешем PDO-соединения.
Параметры процедуры и цепочка результатов
Хранимая процедура — именованная программа в SQL Server. PHP передаёт входные значения, сервер выполняет команды, клиент получает результаты. Результат SELECT, параметр OUTPUT и целочисленный RETURN-код процедуры — разные каналы. Нельзя ожидать, что fetchColumn автоматически извлечёт любой из них.
$id = 0;
$statement->bindParam(2, $id, PDO::PARAM_INT | PDO::PARAM_INPUT_OUTPUT, PDO::SQLSRV_PARAM_OUT_DEFAULT_SIZE);Первый аргумент 2 указывает вторую позицию ?; $id — переменная, а не выражение. Символ | объединяет флаги. bindParam связывает переменную, bindValue передаёт значение и не является способом получить OUTPUT. В PDO_SQLSRV длина больше нуля либо SQLSRV_PARAM_OUT_DEFAULT_SIZE задаёт выход: с PARAM_INPUT_OUTPUT это INOUT, без флага — OUTPUT. Обычному входному параметру не добавляйте длину только ради ограничения поля.
| Что возвращается | Как читать | Когда значение готово |
|---|---|---|
| Строки SELECT | fetch внутри текущего набора | Когда соответствующий набор стал текущим |
| Следующий SELECT | nextRowset, затем чтение его строк | После перехода к этому набору |
| OUTPUT / INOUT | Привязанная переменная PHP | После обработки всей цепочки результатов |
| RETURN процедуры | Явное присваивание RETURN-кода в SQL-вызове и согласованный выход | По тому же договору завершения результатов |
columnCount()>0 помогает отличить набор с полями от промежуточного результата без полей. SET NOCOUNT ON убирает счётчики затронутых строк из протокола, но не подавляет SELECT. Не вставляйте диагностический SELECT в процедуру с фиксированным договором клиента. Ошибка может возникнуть на execute, fetch, nextRowset или при завершении результатов — успешный execute ещё не доказывает успех всего пакета SQL.
В версии 5.13 появился PDO::SQLSRV_ATTR_BATCH_ERROR_CONTINUE. Он управляет продолжением пакетной обработки после ошибок; не превращает ошибки в успешные данные. При включении нужно учитывать диагностику переходов и собственный договор частичного результата. Этот специальный режим описан по исходнику 5.13.3; проекты используют обычное поведение и не называют его выполненным сценарием.
Процедура: две таблицы результата и выходные параметры
Сервис создаёт заявку через хранимую процедуру. Клиенту нужны строка заявки, отдельный статус и присвоенный номер. Проект показывает три разных канала ответа: наборы строк от SELECT, OUTPUT-параметр номера и параметр с начальным значением request, который процедура заменяет на REQUEST:1. Файл procedure.json пригоден как квитанция.
- PHP: title + id=0 + label=request
- Процедура INSERT → id=1
- SELECT заявки → nextRowset → SELECT статуса
- Конец результатов → OUTPUT id=1 и label=REQUEST:1
Минимальный вызов
$statement->bindParam(2, $id, PDO::PARAM_INT | PDO::PARAM_INPUT_OUTPUT, PDO::SQLSRV_PARAM_OUT_DEFAULT_SIZE);
$statement->execute();
while ($statement->nextRowset()) { /* перейти дальше */ }
echo $id;bindParam привязывает переменную по ссылке: драйвер сможет записать в $id выходное значение. Позиции ? считаются с 1. Флаг PARAM_INPUT_OUTPUT обозначает передачу в обе стороны; длина описывает выходной буфер, а не число строк. nextRowset переключает набор целиком, fetch читает строку внутри него. Минимальная схема пропускает ненужные строки; полный проект сохраняет оба SELECT перед чтением OUTPUT.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Наборов данных | 2 |
| Заголовок заявки | Replace filter |
| Статус | saved |
| Выходной id | 1 |
| Изменённая метка | REQUEST:1 |
| Заявка сохранена | да |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
$statement=null;
try {
$config=p177Config();$db=p177Connect($config);$runtime=p177Runtime($db);
$table=p177Qualified(p177OwnTable($db,$owned,'tickets','(id int IDENTITY(1,1) PRIMARY KEY, title varchar(80) NOT NULL)'));
$procedure=p177Qualified(p177OwnName($db,$owned,'PROCEDURE','ticket_proc'));
p177Ddl($db,"CREATE PROCEDURE $procedure @title varchar(80), @id int OUTPUT, @label varchar(80) OUTPUT AS
BEGIN SET NOCOUNT ON;
INSERT INTO $table(title) VALUES(@title);
SET @id=CONVERT(int,SCOPE_IDENTITY());
SET @label=UPPER(@label)+':'+CONVERT(varchar(12),@id);
SELECT id,title FROM $table WHERE id=@id;
SELECT CAST('saved' AS varchar(8)) AS status;
END");
$title='Replace filter';$id=0;$label='request';$sets=[];
$statement=$db->prepare("EXEC $procedure ?, ?, ?");
$statement->bindParam(1,$title,PDO::PARAM_STR);
$statement->bindParam(2,$id,PDO::PARAM_INT|PDO::PARAM_INPUT_OUTPUT,PDO::SQLSRV_PARAM_OUT_DEFAULT_SIZE);
$statement->bindParam(3,$label,PDO::PARAM_STR|PDO::PARAM_INPUT_OUTPUT,80);
$statement->execute();
do {if ($statement->columnCount()>0) {$sets[]=p177Rows($statement);}} while ($statement->nextRowset());
p177Release($statement);
$saved=p177Read($db,"SELECT id,title FROM $table WHERE id=?",[$id]);
$receipt=['id'=>(int)$id,'label'=>$label,'ticket'=>['id'=>(int)$sets[0][0]['id'],'title'=>$sets[0][0]['title']],'status'=>$sets[1][0]['status']];
file_put_contents(__DIR__.'/procedure.json',json_encode($receipt,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n");
$checks=['two_data_rowsets'=>count($sets)===2,'ticket_returned'=>count($sets[0])===1&&$sets[0][0]['title']===$title,
'status_returned'=>$sets[1][0]['status']==='saved','output_id'=>(int)$id===1,
'input_output_label'=>$label==='REQUEST:1','saved_ticket'=>count($saved)===1&&(int)$saved[0]['id']===1,
'statement_released'=>$statement===null,'connection_usable'=>!$db->inTransaction()];
$rows=[['Наборов данных',count($sets)],['Заголовок заявки',$sets[0][0]['title']],['Статус',$sets[1][0]['status']],
['Выходной id',(int)$id],['Изменённая метка',$label],['Заявка сохранена',count($saved)===1]];
$observations=['result_sets'=>$sets,'receipt'=>$receipt];
} finally {
p177Release($statement);
if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}
p177Report('pdo177-procedure-output','Процедура: две таблицы результата и выходные параметры',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Наборов данных — 2
Заголовок заявки — Replace filter
Статус — saved
Выходной id — 1
Изменённая метка — REQUEST:1
Заявка сохранена — да
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Открыть квитанцию процедуры JSON
Границы решения
SET NOCOUNT ON убирает промежуточные сообщения о числе затронутых строк, но не отменяет SELECT и не делает процедуру атомарной. OUTPUT, RETURN-код процедуры и SELECT — разные результаты. Для BIGINT вне поддержанного целочисленного диапазона нужен строковый выход с достаточным буфером; одна разрядность PHP не заменяет проверки договора драйвера. Для многострочного ответа не собирайте неограниченный fetchAll: читайте потоково или ограничивайте данные запросом. Параметр длины у bindParam в PDO_SQLSRV может менять направление, поэтому для обычного входного title он не указан.
Попробуйте изменить условие
Удалите второй SELECT из своей копии процедуры. Должно ли успешное приложение по-прежнему обращаться к $sets[1][0]?
Разбор
Нет. Число и форма наборов — часть договора процедуры и клиента. Изменив договор, обновите чтение и проверку. Нельзя полагаться на фиксированный номер набора, если процедура произвольно добавляет SELECT для отладки.
Жизненный цикл курсора и MARS
Прочитать первую строку и закончить результат — разные действия. У statement могут оставаться строки и следующие наборы. При выключенном MultipleActiveResultSets драйвер не позволит использовать занятое соединение для другого запроса. MARS позволяет несколько активных результатов, но их ресурсы и границы транзакции по-прежнему нужно учитывать.
В этом драйвере closeCursor проходит оставшиеся результаты. Он не заменяет rollback и не гарантирует, что серверная процедура прекратила изменения: поздняя команда или ошибка может обнаружиться при завершении цепочки. Не используйте closeCursor как отмену бизнес-операции. Убирая последнюю ссылку на PDOStatement, учитывайте, что ссылка может храниться и в другой переменной.
Если нужно обработать десятки тысяч строк, сначала определите результат экрана: поля, WHERE, JOIN, GROUP BY, ORDER BY и ограничение количества. Выгружать всё в PHP и затем сортировать массив ради десяти записей — лишняя передача и память. Каталог с выборкой страницы выполняет отбор на сервере; Курсоры и ограниченное окно сравнивает способы чтения уже выбранного результата.
Реальные вопросы о незаполненном OUTPUT, о транзакции внутри перебора и о незаконченных результатах DML помогли выбрать ситуации. Они относятся к разным историческим версиям; решения выше написаны заново, привязаны к текущему профилю и проверяются запуском, а не переносом старых ответов.
Незаконченный SELECT: освободить соединение для следующей команды
Страница взяла только первую строку SELECT и затем пытается выполнить другую команду тем же соединением. При MARS=0 остаются непрочитанные результаты. Проект воспроизводит отказ, освобождает PDOStatement и повторяет нужный запрос успешно. Затем отдельно показывает MARS=1: второе чтение возможно, а первый курсор всё ещё нужно закончить.
- SELECT содержит 3 строки
- PHP прочитал только id=1
- MARS=0: следующая команда отклонена
- closeCursor → COUNT=3 → можно начать транзакцию
Минимальный вызов
$first = $statement->fetch(PDO::FETCH_ASSOC);
$statement->closeCursor();
// Теперь выполняем следующую команду соединения.fetch получает одну строку, но не означает завершение результата. closeCursor завершает работу с оставшимися результатами; это не rollBack. PDOStatement можно затем использовать повторно, либо убрать ссылку присваиванием null. MARS выбирается при подключении, поэтому здесь создаются два отдельных соединения с разными DSN.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| MARS=0: первая строка | 1 |
| Второй запрос до освобождения отклонён | да |
| После closeCursor: строк в таблице | 3 |
| Транзакция после освобождения | да |
| MARS=1: второй запрос дал строк | 3 |
| Первый SELECT дочитан: оставшихся строк | 2 |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
$off=null;$pending=null;
try {
$config=p177Config();$db=p177Connect($config);$runtime=p177Runtime($db);
$table=p177Qualified(p177OwnTable($db,$owned,'lifecycle','(id int PRIMARY KEY, label varchar(20) NOT NULL)'));
p177Command($db,"INSERT INTO $table(id,label) VALUES(1,'first'),(2,'second'),(3,'third')");
$off=p177Connect($config,['MultipleActiveResultSets'=>0]);
$pending=p177Request($off,"SELECT id,label FROM $table ORDER BY id");
$first=$pending->fetch(PDO::FETCH_ASSOC);
$blocked=p177Capture(fn()=>p177Read($off,"SELECT COUNT(*) AS total FROM $table"));
p177Release($pending);
$after=(int)p177Read($off,"SELECT COUNT(*) AS total FROM $table")[0]['total'];
p177Begin($off);$canBegin=$off->inTransaction();p177Rollback($off);p177Close($off);
// Основное соединение явно создано с MARS=1.
$pending=p177Request($db,"SELECT id,label FROM $table ORDER BY id");
$onFirst=$pending->fetch(PDO::FETCH_ASSOC);
$parallel=(int)p177Read($db,"SELECT COUNT(*) AS total FROM $table")[0]['total'];
$tail=p177Rows($pending);p177Release($pending);
p177Begin($db);$onCanBegin=$db->inTransaction();p177Rollback($db);
$csv="mars,first_id,second_query,after_close\n0,".$first['id'].",pending_results,$after\n1,".$onFirst['id'].",$parallel,$parallel\n";
file_put_contents(__DIR__.'/lifecycle.csv',$csv);
$checks=['off_first_row'=>(int)$first['id']===1,'pending_rejected'=>$blocked['failed']&&$blocked['state']==='IMSSP',
'after_release_count'=>$after===3,'transaction_after_release'=>$canBegin,
'mars_allows_second'=>$parallel===3,'remaining_rows'=>array_map('intval',array_column($tail,'id'))===[2,3],
'transaction_after_drain'=>$onCanBegin,'connections_clean'=>$off===null&&$pending===null&&!$db->inTransaction()];
$rows=[['MARS=0: первая строка',(int)$first['id']],['Второй запрос до освобождения отклонён',$blocked['failed']],
['После closeCursor: строк в таблице',$after],['Транзакция после освобождения',$canBegin],
['MARS=1: второй запрос дал строк',$parallel],['Первый SELECT дочитан: оставшихся строк',count($tail)]];
$observations=['pending_error'=>$blocked,'remaining'=>$tail,'csv'=>$csv];
} finally {
p177Release($pending);p177Close($off);
if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}
p177Report('pdo177-mars-lifecycle','Незаконченный SELECT: освободить соединение для следующей команды',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
MARS=0: первая строка — 1
Второй запрос до освобождения отклонён — да
После closeCursor: строк в таблице — 3
Транзакция после освобождения — да
MARS=1: второй запрос дал строк — 3
Первый SELECT дочитан: оставшихся строк — 2
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать последовательность команд CSV
Границы решения
MARS позволяет несколько активных результатов на соединении, но не превращает PHP-код в параллельные потоки и не разрешает произвольно менять границы транзакции посреди активного запроса. Здесь транзакция начинается только после освобождения результатов. closeCursor в этом драйвере дочитывает цепочку, поэтому поздняя ошибка SQL может обнаружиться именно там; не скрывайте её пустым catch. Для массовой обработки чаще полезнее законченная ограниченная выборка, JOIN или SQL-агрегация, чем запрос внутри каждого шага foreach.
Попробуйте изменить условие
Если экрану нужна только одна запись, чем лучше ограничить работу: SELECT всех строк с одним fetch или запросом с WHERE по ключу?
Разбор
Запросом по ключу с перечислением нужных столбцов. Тогда база и канал не готовят ненужные данные, а жизненный цикл результата проще. closeCursor освобождает ресурсы, но не является заменой правильно составленному запросу.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


