Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

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

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 297 · После подключения, параметров и чтения PDO

PDO_SQLSRV: транзакции, снимки, процедуры и незаконченные результаты

Сохранить заказ с остатком, сравнить видимость двух сессий, получить OUTPUT и освободить соединение для следующего запроса.

Связать изменения и завершать результаты

Заказ должен появиться одновременно со списанием остатка. Отчёт должен читать данные по понятным правилам. После запроса соединение должно быть готово к следующему действию. Эти задачи связаны состоянием PDO-соединения: у него есть текущая транзакция, а у PDOStatement — ещё не законченные результаты.

Три разных состояния
  1. Соединение PDO: открыто и авторизовано
  2. Транзакция: несколько изменений до COMMIT/ROLLBACK
  3. PDOStatement: строки и следующие наборы результатов
  4. Освободить результат и закончить транзакцию — разные действия

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 показывает только подтверждённый заказ.

Путь данных и полученный результат
  1. stock: 5 деталей
  2. UPDATE с available>=2 → остаток 3
  3. INSERT заказа 101 → COMMIT
  4. Ошибочная заявка → 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
Остаток после COMMIT3
Остаток после ошибки и ROLLBACK3
Изменено при запросе 4 из 30
Заказов в базе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 видно, почему один отчёт может получить разные значения или сохранить прежний снимок.

Путь данных и полученный результат
  1. Читатель прочитал 10
  2. Другой PDO записал и подтвердил 20
  3. READ COMMITTED → 20; SNAPSHOT → прежние 10
  4. Завершили 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: после чужого COMMIT20
SNAPSHOT: первое чтение10
SNAPSHOT: после чужого COMMIT10
После завершения SNAPSHOT20
Самостоятельный проект, исходник и архив
Два чтения отчёта: свежие данные или один снимокСамостоятельный пример
<?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. Обычному входному параметру не добавляйте длину только ради ограничения поля.

Что возвращаетсяКак читатьКогда значение готово
Строки SELECTfetch внутри текущего набораКогда соответствующий набор стал текущим
Следующий SELECTnextRowset, затем чтение его строкПосле перехода к этому набору
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 пригоден как квитанция.

Путь данных и полученный результат
  1. PHP: title + id=0 + label=request
  2. Процедура INSERT → id=1
  3. SELECT заявки → nextRowset → SELECT статуса
  4. Конец результатов → 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
Выходной id1
Изменённая метка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: второе чтение возможно, а первый курсор всё ещё нужно закончить.

Путь данных и полученный результат
  1. SELECT содержит 3 строки
  2. PHP прочитал только id=1
  3. MARS=0: следующая команда отклонена
  4. 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
Самостоятельный проект, исходник и архив
Незаконченный SELECT: освободить соединение для следующей командыСамостоятельный пример
<?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. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.