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

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

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

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

Глава 296 · После PDO_SQLSRV-подключения и параметризованной выборки

PDO_SQLSRV: даты, точные значения, вложения и ограниченное окно результатов

Расписание с микросекундами, точные суммы, бинарный файл и три строки каталога: практические формы результатов PDO_SQLSRV.

Получить файл с тем же смыслом данных

PDO связывает PHP-приложение с базой: объект соединения создаёт команды, а PDOStatement позволяет получить их результат. PDO_SQLSRV реализует этот договор для SQL Server. Общий интерфейс сохраняется, но параметры подключения, типы, курсоры и границы результатов зависят от драйвера.

Проекты рассчитаны на отдельный SQL Server LocalDB. Проверяемый профиль: PHP 8.5.11 и PDO_SQLSRV 5.13.3. Для рабочего приложения нужно собственное подключение с необходимыми правами. Начало SQL и PDO объясняет таблицу, запрос и получение одной строки.

Экспорт должен сохранить смысл данных: номер 9007199254740993 — тот же номер, 0.10 — точная десятичная сумма, дата со смещением +03:00 — тот же момент, а вложение — те же байты. Успешного SELECT для этого недостаточно: нужно выбрать SQL-типы, форму PHP-результата и формат файла, который поймёт получатель.

Нужен результатЧто выбирает SQLЧто формирует PHP
Расписание дня — dates.csvДва визита диапазона дат, datetime2 и datetimeoffsetЯвное представление даты, смещения и NULL
Сверка сумм — values.csvDECIMAL, MONEY, BIGINT и расчёт над точными SQL-значениямиСтроки без промежуточного float; отдельно формат отображения
Одно вложение — attachment.binVARBINARY(MAX) только выбранной записиПроверка размера/байтов и сохранение файла
Окно каталога — window.csvWHERE, ORDER BY и OFFSET/FETCH ограничивают три строкиВывод выбранных товаров и перемещение внутри результата
Три разных договора на примере цены
  1. SQL: DECIMAL(18,2) хранит 0.10
  2. PDO_SQLSRV: PHP string содержит точную десятичную запись
  3. CSV: поле 0.10 передаётся получателю с оговорённым типом
  4. Форматирование экрана не выполняет UPDATE и не задаёт валюту

Предполагаются подключение и подготовленный запрос и основы выборки данных. Здесь настройка соединения относится к одному PDO, настройка запроса — к одному PDOStatement. Константа после PDO:: выбирает параметр; стрелка → в схеме означает переход данных, а -> в коде вызывает метод объекта. Значение true включает возможность, false отключает; успешность методов проверяется отдельно. SQL NULL, пустая строка, ноль и конец результата — разные состояния.

Примеры используют небольшой собственный набор и реальные запросы через PDO_SQLSRV 5.13.3. Они не измеряют производительность гигабайтных файлов и не доказывают, что атрибуты другой PDO-реализации ведут себя так же. В каждом ZIP есть полный запуск и README; HTML показывает полезные данные, JSON — проверки и наблюдения, третья ссылка ведёт к самому файлу.

Даты: строка, DateTime и известное смещение

Внешней системе нужно расписание с понятным временем, а внутри PHP иногда нужно сравнить даты или сменить представление. PDO_SQLSRV по умолчанию возвращает SQL-даты строками. SQLSRV_ATTR_FETCHES_DATETIME_TYPE=true просит DateTime для поддерживаемых временных типов; это настройка получения, а не изменение столбца.

Одна запись в двух представлениях
  1. datetime2(6): 2026-11-08 09:05:06.123456 — местное время без зоны
  2. datetimeoffset(6): те же дата/время со смещением +03:00
  3. Обычный fetch: строки; fetch с атрибутом: DateTime
  4. format явно сохраняет микросекунды и только известное смещение
$statement = $db->prepare(
    "SELECT CAST('2026-11-08T09:05:06.123456+03:00' AS datetimeoffset(6)) AS appointment"
);
$statement->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE, true);
$statement->execute();
$appointment = $statement->fetchColumn();
echo $appointment->format('Y-m-d\TH:i:s.uP');
// 2026-11-08T09:05:06.123456+03:00
$statement->closeCursor();

prepare создаёт команду, setAttribute задаёт представление её результата, execute выполняет её. fetchColumn берёт первый столбец первой строки. В шаблоне format Y-m-d — календарная дата, H:i:s — часы, минуты и секунды, u — шесть цифр микросекунд, P — известное смещение. Обратная косая черта перед T оставляет буквальный разделитель. Данные из NULL не превращаются в DateTime: перед вызовом format это состояние проверяется явно.

Настройка или типПолное правило и граница
PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPEbool, по умолчанию false; connection или statement, statement переопределяет connection; поддержка с драйвера 5.6
date, time, smalldatetime, datetime, datetime2, datetimeoffsetАтрибут относится к обычному получению этих временных SQL-типов; исходная точность типа остаётся ограничением
ATTR_STRINGIFY_FETCHES / FETCHES_NUMERIC_TYPEНе заменяют datetime attribute и не преобразуют дату по числовым правилам
datetime2 и timezoneДата/время не содержат зоны. Зона PHP-объекта не доказывает, что исходное SQL-значение было UTC или имело это смещение
datetimeoffsetСодержит смещение, но не полное правило именованной зоны и её будущих переходов
OUTPUT параметр датыDateTime как output-параметр не поддерживается; отдельный строковый договор процедуры описан в главе транзакций
Результат/ошибка настройкиsetAttribute возвращает bool; при ERRMODE_EXCEPTION отказ даёт PDOException. Нельзя продолжать как будто формат включён

Первое применение — экспорт двух визитов. Второе — временно включить DateTime только для вычисления интервала, сохранив строковый договор остальных запросов. Если библиотечная функция меняет connection attribute, она должна вернуть прежнее значение в finally. В проекте мы сохраняем именно исходную настройку, а не навязываем другой части приложения false.

Обсуждение разных дат при переходе с SQLSRV на PDO полезно как описание проблемы переноса. ReturnDatesAsStrings из native SQLSRV нельзя вставлять в PDO DSN: здесь работает показанный атрибут. Дополнительное форматирование и календарная арифметика разобраны в главе о датах.

Выгрузить визиты дня, сохранив микросекунды и известное смещение

Диспетчер передаёт подрядчику визиты на один день. В dates.csv должны попасть только два задания 8 ноября, дробные секунды не должны исчезнуть, а известное смещение +03:00 нужно сохранить. Визит 9 ноября не загружается ради фильтрации в PHP. Программа сравнивает строки и DateTime, показывает приоритет настройки statement и возвращает прежний режим соединения.

Путь данных и полученный результат
  1. В таблице три визита: два 8 ноября и один 9 ноября
  2. SQL выбирает 8 ноября по диапазону local_at
  3. Строки → DateTime → CSV с шестью цифрами микросекунд
  4. Statement override вернул строки; connection attribute восстановлен

Минимальный вызов

$statement = $db->prepare('SELECT local_at,offset_at FROM VISITS WHERE id=?');
$statement->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,true);
$statement->execute([1]);
$row=$statement->fetch(PDO::FETCH_ASSOC);
if ($row===false) {throw new RuntimeException('Визит не найден');}
echo $row['local_at']->format('Y-m-d\TH:i:s.u');
// Для строки проекта: 2026-11-08T09:05:06.123456
$statement->closeCursor();

VISITS в коротком фрагменте — уже существующая таблица. Placeholder ? принимает ID, а атрибут изменяет форму результата этого statement. local_at — DateTime, но его формат не включает P: у SQL datetime2 нет известного смещения. Для offset_at формат добавляет P только после отдельной проверки NULL. Полный проект создаёт свою таблицу и использует диапазон дат с включённой нижней и исключённой верхней границей.

Что получится после запуска

НаблюдениеРезультат
Визитов выбранного дня2
Первый визитМонтаж кабеля — 2026-11-08T09:05:06.123456
Известное смещение первого визита2026-11-08T09:05:06.123456+03:00
Смещение второго визитаSQL NULL
Строковый запрос после override2026-11-08 09:05:06.123456
Файлdates.csv
Самостоятельный проект, исходник и архив
Выгрузить визиты дня, сохранив микросекунды и известное смещениеСамостоятельный пример
<?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);

function writeCsv177(string $name,array $records): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('CSV не открыт.');}
    try {
        foreach ($records as $record) {
            if (fputcsv($file,$record,';','"','',"\n")===false) {throw new RuntimeException('CSV записан не полностью.');}
        }
    } finally {fclose($file);}
}

    $name=p177OwnTable($db,$owned,'dates','(id int PRIMARY KEY,task nvarchar(80) NOT NULL,local_at datetime2(6) NOT NULL,offset_at datetimeoffset(6) NULL)');
    $t=p177Qualified($name);
    foreach ([
        [1,'Монтаж кабеля','2026-11-08T09:05:06.123456','2026-11-08T09:05:06.123456+03:00'],
        [2,'Проверка щита','2026-11-08T14:30:00.000000',null],
        [3,'Визит завтра','2026-11-09T10:00:00.000000','2026-11-09T10:00:00.000000+03:00'],
    ] as $record) {p177Command($db,'INSERT INTO '.$t.' (id,task,local_at,offset_at) VALUES (?,?,?,?)',$record);}
    $sql='SELECT id,task,local_at,offset_at FROM '.$t.' WHERE local_at>=? AND local_at<? ORDER BY local_at,id';
    $parameters=['2026-11-08T00:00:00','2026-11-09T00:00:00'];
    $saved=(bool)$db->getAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE);
    $objects=$default=$overridden=$restored=[];$s=null;
    try {
        $db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,false);
        $default=p177Read($db,$sql,$parameters);
        $s=$db->prepare($sql);
        if (!$s->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,true)) {throw new RuntimeException('DateTime-режим не включён.');}
        $s->execute($parameters);$objects=p177Rows($s);p177Release($s);
        $db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,true);
        $s=$db->prepare($sql);
        if (!$s->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,false)) {throw new RuntimeException('Строковый override не включён.');}
        $s->execute($parameters);$overridden=p177Rows($s);p177Release($s);
    } finally {p177Release($s);$db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE,$saved);}
    $restored=(bool)$db->getAttribute(PDO::SQLSRV_ATTR_FETCHES_DATETIME_TYPE)===$saved;
    $csv=[['id','task','local_datetime','known_offset_datetime']];$display=[];
    foreach ($objects as $row) {
        if (!($row['local_at'] instanceof DateTimeInterface)
            || ($row['offset_at']!==null && !($row['offset_at'] instanceof DateTimeInterface))) {
            throw new RuntimeException('Получено неверное представление даты.');
        }
        $local=$row['local_at']->format('Y-m-d\TH:i:s.u');
        $offset=$row['offset_at']===null?'[NULL]':$row['offset_at']->format('Y-m-d\TH:i:s.uP');
        $csv[]=[(int)$row['id'],$row['task'],$local,$offset];
        $display[]=['id'=>(int)$row['id'],'task'=>$row['task'],'local'=>$local,'offset'=>$offset,
            'local_php_type'=>get_debug_type($row['local_at']),'offset_php_type'=>get_debug_type($row['offset_at'])];
    }
    writeCsv177('dates.csv',$csv);
    $rows=[['Визитов выбранного дня',count($display)],['Первый визит',$display[0]['task'].' — '.$display[0]['local']],
        ['Известное смещение первого визита',$display[0]['offset']],['Смещение второго визита','SQL NULL'],
        ['Строковый запрос после override',$overridden[0]['local_at']],['Файл','dates.csv']];
    $checks=['only_selected_day'=>array_map('intval',array_column($objects,'id'))===[1,2],
        'default_is_string'=>is_string($default[0]['local_at'])&&is_string($default[0]['offset_at']),
        'datetime_objects'=>$objects[0]['local_at'] instanceof DateTimeInterface && $objects[0]['offset_at'] instanceof DateTimeInterface,
        'microseconds_preserved'=>$display[0]['local']==='2026-11-08T09:05:06.123456',
        'offset_preserved'=>$display[0]['offset']==='2026-11-08T09:05:06.123456+03:00',
        'null_preserved'=>$objects[1]['offset_at']===null && $default[1]['offset_at']===null,
        'statement_override'=>$overridden===$default,'connection_restored'=>$restored,
        'artifact_written'=>is_file(__DIR__.'/dates.csv')];
    $observations=['string_rows'=>$default,'date_object_projection'=>$display,'override_rows'=>$overridden,
        'datetime2_contains_timezone'=>false,'original_connection_attribute'=>$saved];

} finally {

    if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}

p177Report('pdo177-date-export','Выгрузить визиты дня, сохранив микросекунды и известное смещение',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Визитов выбранного дня — 2
Первый визит — Монтаж кабеля — 2026-11-08T09:05:06.123456
Известное смещение первого визита — 2026-11-08T09:05:06.123456+03:00
Смещение второго визита — SQL NULL
Строковый запрос после override — 2026-11-08 09:05:06.123456
Файл — dates.csv

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать расписание CSV

Границы решения

Местная дата не становится UTC из-за наличия DateTime. Смещение +03:00 не является названием зоны с историей переходов. Этот пример не меняет timezone процесса. Переключение представления не меняет сохранённые значения; точность ограничена исходным datetime2(6)/datetimeoffset(6). Сначала завершайте результаты своего запроса, затем восстанавливайте настройки.

Попробуйте изменить условие

Получателю нужен момент первого визита в UTC. Можно ли просто заменить +03:00 на Z в строке?

Разбор

Нет: 09:05:06+03:00 соответствует 06:05:06Z. Для offset_at создайте DateTimeImmutable::createFromInterface, вызовите setTimezone(new DateTimeZone("UTC")) и форматируйте результат. Для local_at сначала нужен явный договор о зоне; из одного datetime2 его не получить.

Числовые типы и формат денег

Для отчёта о платеже нужны точная сумма и тот же номер операции. Автоматическое приведение всех значений к float может изменить длинный номер и десятичную сумму. SQLSRV_ATTR_FETCHES_NUMERIC_TYPE управляет только определёнными SQL-типами; слово numeric в имени не обещает, что любой NUMERIC станет PHP float.

Семейство настройкиЗначение и результатПрактическая граница
SQLSRV_ATTR_FETCHES_NUMERIC_TYPEbool; по умолчанию false. bit/int/smallint/tinyint становятся int, float/real — floatНе распространять этот список на DECIMAL, NUMERIC и BIGINT
PDO::ATTR_STRINGIFY_FETCHEStrue снова требует строки даже при numeric flagПриоритет важен для публичного JSON; явная bindColumn PARAM_INT имеет отдельное правило
SQLSRV_ATTR_FORMAT_DECIMALSbool false по умолчанию; добавляет ведущий ноль десятичной записи ниже единицыНе делает денежную арифметику точнее: значение и так хранится десятично
SQLSRV_ATTR_DECIMAL_PLACESint 0…4, только при FORMAT_DECIMALS; влияет на MONEY/SMALLMONEYНе меняет scale DECIMAL/NUMERIC, данные SQL или правила валюты; значения вне диапазона игнорируются
Область настройкиconnection или statement; statement может переопределить соединениеВосстановить изменённые настройки после своего экспорта
OUTPUT и INOUTЧисловое форматирование не поддерживается для output-параметровПроцедура возвращает отдельно согласованный тип; не рассчитывать на display attributes
Что сохранить без потерь
  1. BIGINT 9007199254740993 → PHP string → CSV тот же номер
  2. DECIMAL 0.10 × INT 3 → SQL CAST(... AS DECIMAL(18,2)) → string 0.30
  3. MONEY 0.1250 → display 0.13 при двух местах; новое чтение SQL всё ещё 0.1250
  4. Числовой flag превращает qty="3" в int3, но не отменяет этот договор
$statement = $db->prepare(
    "SELECT CAST(0.10 * 3 AS decimal(18,2)) AS total"
);
$statement->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS, true);
$statement->execute();
echo $statement->fetchColumn(); // 0.30, точная строка
$statement->closeCursor();

CAST задаёт SQL-тип результата выражения; десятичная точка в SQL не является PHP float. Мы не считаем деньги в PHP оператором умножения над полученной строкой. Для другого масштаба, округления и валюты сначала нужен явный договор. Второй сценарий применения — API, которому нужны настоящие int количества: включите numeric flag для его запроса и проверьте is_int, сохранив суммы и длинные идентификаторы строками.

Вопрос о числах, которые PDO возвращает строками показывает, почему одного SQL-типа недостаточно для предположения о PHP-значении. Старые ответы о MySQL и утверждения «PDO всегда возвращает строку» не определяют современный PDO_SQLSRV. Проект сверки сумм показывает исходный, числовой, строковый и денежный режимы на одном собственном наборе.

Для HTML деньги можно отдельно форматировать по локали; экспорт машинного протокола не должен внезапно заменить точку запятой. В CSV здесь разделитель ;, UTF-8 и LF; импортёр обязан считать business_id и amount текстовыми десятичными полями. Открытие файла в таблицах с автоматическим распознаванием чисел может изменить отображение длинного номера — это отдельный этап после корректного файла.

Выгрузить точные суммы и длинные номера с отдельным денежным форматом

Бухгалтер получает три строки сверки. Большой архивный платёж и длинный внешний номер должны сохраниться побайтно в десятичной записи, кабель по 0.10 в количестве 3 должен дать 0.30, а комиссию удобно показать с двумя знаками. Программа меняет только представление результата; отдельное SQL-чтение доказывает, что комиссия осталась 0.5000.

Путь данных и полученный результат
  1. SQL: номер9007199254740993, amount0.10, qty3, fee0.5000
  2. Numeric flag: qty string3 → int3; DECIMAL и BIGINT остаются строками
  3. Stringify: qty снова string3; money display отдельно даёт0.50
  4. CSV содержит точные суммы; SQL readback подтверждает прежние0.10/0.5000

Минимальный вызов

$statement=$db->prepare('SELECT amount,qty,fee FROM RECEIPT_LINES WHERE id=?');
$statement->setAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE,true);
$statement->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS,true);
$statement->setAttribute(PDO::SQLSRV_ATTR_DECIMAL_PLACES,2);
$statement->execute([2]);
$item=$statement->fetch(PDO::FETCH_ASSOC);
// В проекте: amount — string "0.10", qty — int3, fee — string "0.50".
$statement->closeCursor();

Три атрибута отвечают за разные решения: числовые PHP-типы, ведущий ноль и число отображаемых разрядов MONEY. DECIMAL_PLACES=2 не округляет amount до новой точности. В полном проекте stringification дополнительно включается на соединении, чтобы увидеть приоритет. Исходные настройки сохранены и восстановлены, а строки сравниваются строго.

Что получится после запуска

НаблюдениеРезультат
Длинный номер9007199254740993
Большая точная сумма1234567890123456.78
Кабель: 0.10 × 30.30
Комиссия в CSV0.50
Комиссия в SQL после экспорта0.5000
Количество: режимы PHPstring → int → string
Состояния примечанияNULL; EMPTY; TEXT:0
Файлvalues.csv
Самостоятельный проект, исходник и архив
Выгрузить точные суммы и длинные номера с отдельным денежным форматомСамостоятельный пример
<?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);

function writeCsv177(string $name,array $records): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('CSV не открыт.');}
    try {
        foreach ($records as $record) {
            if (fputcsv($file,$record,';','"','',"\n")===false) {throw new RuntimeException('CSV записан не полностью.');}
        }
    } finally {fclose($file);}
}

    $name=p177OwnTable($db,$owned,'values','(id int PRIMARY KEY,business_id bigint NOT NULL,title nvarchar(80) NOT NULL,amount decimal(18,2) NOT NULL,qty int NOT NULL,fee money NOT NULL,note nvarchar(40) NULL)');
    $t=p177Qualified($name);
    foreach ([
        [1,'9007199254740993','Архивный платёж','1234567890123456.78',1,'-0.5000',null],
        [2,'9007199254740994',"Кабель O'Reilly",'0.10',3,'0.5000',''],
        [3,'9007199254740995','Контроль','0.00',0,'0.0000','0'],
    ] as $row) {p177Command($db,'INSERT INTO '.$t.' (id,business_id,title,amount,qty,fee,note) VALUES (?,?,?,?,?,?,?)',$row);}
    $sql='SELECT id,business_id,title,amount,qty,CAST(amount*qty AS decimal(18,2)) AS line_total,fee,note FROM '.$t.' ORDER BY id';
    $originalNumeric=(bool)$db->getAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE);
    $originalStringify=(bool)$db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES);
    $s=null;$plain=$numeric=$stringified=$formatted=[];
    try {
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,false);
        $db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE,false);
        $plain=p177Read($db,$sql);
        $db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE,true);
        $numeric=p177Read($db,$sql);
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,true);
        $stringified=p177Read($db,$sql);
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,false);
        $s=$db->prepare($sql);
        $s->setAttribute(PDO::SQLSRV_ATTR_FORMAT_DECIMALS,true);
        $s->setAttribute(PDO::SQLSRV_ATTR_DECIMAL_PLACES,2);
        $s->execute();$formatted=p177Rows($s);p177Release($s);
    } finally {
        p177Release($s);
        $db->setAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE,$originalNumeric);
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,$originalStringify);
    }
    $stored=p177Read($db,'SELECT CONVERT(varchar(50),amount) AS amount_text,CONVERT(varchar(50),CAST(fee AS decimal(18,4))) AS fee_text FROM '.$t.' WHERE id=2')[0];
    $csv=[['business_id','title','amount','qty','line_total','fee_display','note_state']];$states=[];
    foreach ($formatted as $item) {
        $state=$item['note']===null?'NULL':($item['note']===''?'EMPTY':'TEXT:'.$item['note']);$states[]=$state;
        $csv[]=[$item['business_id'],$item['title'],$item['amount'],$item['qty'],$item['line_total'],$item['fee'],$state];
    }
    writeCsv177('values.csv',$csv);
    $rows=[['Длинный номер',$formatted[0]['business_id']],['Большая точная сумма',$formatted[0]['amount']],
        ['Кабель: 0.10 × 3',$formatted[1]['line_total']],['Комиссия в CSV',$formatted[1]['fee']],
        ['Комиссия в SQL после экспорта',$stored['fee_text']],['Количество: режимы PHP',get_debug_type($plain[1]['qty']).' → '.get_debug_type($numeric[1]['qty']).' → '.get_debug_type($stringified[1]['qty'])],
        ['Состояния примечания',implode('; ',$states)],['Файл','values.csv']];
    $checks=['bigint_exact'=>is_string($formatted[0]['business_id'])&&$formatted[0]['business_id']==='9007199254740993',
        'decimal_exact'=>is_string($formatted[0]['amount'])&&$formatted[0]['amount']==='1234567890123456.78',
        'sql_decimal_product'=>$formatted[1]['line_total']==='0.30',
        'default_numeric_string'=>is_string($plain[1]['qty'])&&$plain[1]['qty']==='3',
        'numeric_int'=>$numeric[1]['qty']===3,'stringify_wins'=>$stringified[1]['qty']==='3',
        'decimal_not_float'=>is_string($numeric[1]['amount'])&&is_string($numeric[0]['business_id']),
        'money_display'=>$formatted[0]['fee']==='-0.50'&&$formatted[1]['fee']==='0.50'&&$formatted[2]['fee']==='0.00',
        'database_values_unchanged'=>$stored===['amount_text'=>'0.10','fee_text'=>'0.5000'],
        'null_empty_zero'=>$states===['NULL','EMPTY','TEXT:0'],
        'attributes_restored'=>(bool)$db->getAttribute(PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE)===$originalNumeric && (bool)$db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES)===$originalStringify,
        'artifact_written'=>is_file(__DIR__.'/values.csv')];
    $observations=['default_rows'=>$plain,'numeric_rows'=>$numeric,'stringified_rows'=>$stringified,'formatted_rows'=>$formatted,
        'stored_readback'=>$stored,'original_attributes'=>['numeric'=>$originalNumeric,'stringify'=>$originalStringify]];

} finally {

    if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}

p177Report('pdo177-exact-values','Выгрузить точные суммы и длинные номера с отдельным денежным форматом',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Длинный номер — 9007199254740993
Большая точная сумма — 1234567890123456.78
Кабель: 0.10 × 3 — 0.30
Комиссия в CSV — 0.50
Комиссия в SQL после экспорта — 0.5000
Количество: режимы PHP — string → int → string
Состояния примечания — NULL; EMPTY; TEXT:0
Файл — values.csv

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать сверку точных значений

Границы решения

Не преобразуйте весь результат в float. PHP string здесь является точной формой передачи SQL-значения, а не признаком ошибки схемы. Показ комиссии с двумя знаками — договор отображения, не денежная арифметика для любой валюты. В другом профиле сначала проверьте фактические типы; assertion намеренно фиксирует данный драйвер, а не все реализации PDO.

Попробуйте изменить условие

Если CSV нужен для машинной сверки комиссии с четырьмя знаками, что менять? Нужно ли увеличивать precision amount?

Разбор

Укажите DECIMAL_PLACES=4 для MONEY display либо экспортируйте явный CAST(fee AS decimal(18,4)) с согласованной строковой записью. amount не меняется. При изменении протокола обновите ожидаемый CSV: 0.5000 вместо0.50. SQL UPDATE для представления результата не нужен.

Двоичные данные: binding, память и пустые состояния

Пользователь запрашивает одно вложение заявки. Список вложений показывает имя и размер; содержимое выбирается по нужному ID после проверки доступа. VARBINARY(MAX) допускает нулевой байт и последовательности, которые не являются UTF-8. Такое содержимое нельзя вставлять как обычную строку HTML/JSON.

Граница потока в PDO_SQLSRV 5.13.3
  1. Контролируемый PHP stream → bindParam PARAM_LOB + ENCODING_BINARY
  2. execute передаёт бинарные значения SQL Server
  3. SELECT нужного ID → драйвер получает содержимое как PHP string
  4. Файл сохраняет исходные байты; HTML показывает HEX, размер и SHA256
$input = fopen($controlledPath, 'rb');
if ($input === false) { throw new RuntimeException('Файл не открыт'); }
$statement = $db->prepare('INSERT INTO ATTACHMENTS (ID,PAYLOAD) VALUES (?,?)');
$statement->bindValue(1, $id, PDO::PARAM_INT);
$statement->bindParam(2, $input, PDO::PARAM_LOB, 0, PDO::SQLSRV_ENCODING_BINARY);
try { $statement->execute(); }
finally { $statement->closeCursor(); if (is_resource($input)) { fclose($input); } }

Второй placeholder получает переменную по ссылке. PARAM_LOB сообщает способ передачи содержимого; пятый аргумент задаёт бинарную кодировку именно этого параметра. Ноль в четвёртом аргументе оставляет его входным: ненулевая длина bindParam в PDO_SQLSRV имеет специальный смысл OUTPUT/INOUT, описанный в главе о нескольких результатах и процедурах. Путь controlledPath и ID в коротком фрагменте уже выбраны приложением; полный проект создаёт собственный источник.

ОперацияПравило
Кодировка соединенияSQLSRV_ENCODING_UTF8 для обычного текста; BINARY нельзя устанавливать как encoding соединения
Бинарный параметр/колонкаENCODING_BINARY можно передать в bindParam/bindColumn либо настройку statement; не перекодировать произвольные байты как текст
PARAM_LOB inputПоток остаётся доступным до execute. Исчерпанный поток нужно перемотать перед повторной отправкой; это не делает повтор бизнес-записи безопасным
Обычное получение VARBINARYPinned driver возвращает строку; выходное streaming не реализовано. В подготовке bindColumn PARAM_LOB дал memory stream общего PDO над уже полученными байтами; это не ленивое чтение с сервера
NULL, пустой файл, отсутствующая строкаSQL IS NULL и DATALENGTH отличают состояния столбца; fetch===false означает отсутствие строки, а не пустое содержимое
ОшибкиПроверить fopen/fwrite/file_put_contents; PDOException не означает, что другой код автоматически откатил транзакцию
ПамятьВ этом проекте файл маленький и ограниченный. Для большого ответа сначала проверяют сохранённые метаданные, права и лимит, не загружают все BLOB каталога

Второе полезное применение — выгрузить уже сохранённое содержимое. Полный проект вложения привязывает выбранную выходную колонку через bindColumn с PARAM_STR и ENCODING_BINARY, читает FETCH_BOUND и сохраняет полученную строку. PARAM_LOB в нём применяется на входе. Отдельное наблюдение подготовки с выходным PARAM_LOB показало memory stream общего PDO; оно объясняет границу реализации, а не подменяет код этого проекта. Наличие SHA само по себе не проверяет доверие к загруженному содержимому: здесь оно доказывает совпадение с известной учебной строкой.

Скачать одно бинарное вложение и отличить пустой файл от отсутствующего

У заявки три состояния вложения: акт монтажа с содержимым, разрешённый пустой шаблон и ещё не присланный файл. Список получает только имя и метаданные; байты выбираются отдельно для акта. Проект отправляет источник через PARAM_LOB, сохраняет attachment.bin и показывает HEX, чтобы нулевой байт и FF не ломали HTML или JSON.

Путь данных и полученный результат
  1. Три SQL-строки: акт 21 байт, пустой шаблон 0 байт, NULL
  2. Список читает только id/title/is_missing/byte_count
  3. Отдельный SELECT id=1 получает бинарную строку
  4. attachment.bin сохраняет 21 байт; HEX и SHA подтверждают совпадение

Минимальный вызов

$statement=$db->prepare('SELECT payload FROM ATTACHMENTS WHERE id=?');
$statement->execute([1]);
$bytes=$statement->fetchColumn();
if ($bytes===false) {throw new RuntimeException('Запись не найдена');}
if ($bytes===null) {throw new RuntimeException('Вложение не прислано');}
echo strlen($bytes); // В полном проекте: 21.
$statement->closeCursor();

fetchColumn читает одну колонку одной строки. false означает отсутствие строки; null — SQL NULL; строка нулевой длины — пустое содержимое. Полный пример дополнительно проверяет SQL IS NULL и DATALENGTH и явно задаёт бинарную кодировку bindColumn. Для отправки NULL в бинарный столбец используется переменная null с PARAM_LOB и ENCODING_BINARY: PARAM_NULL вместе с пятым аргументом кодировки этот драйвер отвергает.

Что получится после запуска

НаблюдениеРезультат
ВложениеАкт монтажа
Байт получено21
HEX50444f31373700ff0d0a4154544143484d454e540a
Содержимое совпало с источникомда
Три состоянияFILE:21; EMPTY; NULL
Файлattachment.bin
Самостоятельный проект, исходник и архив
Скачать одно бинарное вложение и отличить пустой файл от отсутствующегоСамостоятельный пример
<?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);

    $name=p177OwnTable($db,$owned,'attachment','(id int PRIMARY KEY,title nvarchar(80) NOT NULL,payload varbinary(max) NULL)');
    $t=p177Qualified($name);$payload="PDO177\0\xFF\r\nATTACHMENT\n";
    $input=fopen('php://temp','w+b');$s=null;
    if ($input===false) {throw new RuntimeException('Учебный поток не открыт.');}
    try {
        if (fwrite($input,$payload)!==strlen($payload) || !rewind($input)) {throw new RuntimeException('Источник не подготовлен.');}
        $bound=$input;
        $s=$db->prepare('INSERT INTO '.$t.' (id,title,payload) VALUES (?,?,?)');
        $s->bindValue(1,1,PDO::PARAM_INT);$s->bindValue(2,'Акт монтажа',PDO::PARAM_STR);
        $s->bindParam(3,$bound,PDO::PARAM_LOB,0,PDO::SQLSRV_ENCODING_BINARY);
        $s->execute();$inputTypeAfter=get_debug_type($bound);p177Release($s);
    } finally {p177Release($s);if (is_resource($input)) {fclose($input);}}
    $s=$db->prepare('INSERT INTO '.$t.' (id,title,payload) VALUES (?,?,?)');
    try {
        foreach ([[2,'Пустой шаблон',''],[3,'Файл ожидается',null]] as $fixture) {
            $emptyOrNull=$fixture[2];
            $s->bindValue(1,$fixture[0],PDO::PARAM_INT);$s->bindValue(2,$fixture[1],PDO::PARAM_STR);
            $s->bindParam(3,$emptyOrNull,PDO::PARAM_LOB,0,PDO::SQLSRV_ENCODING_BINARY);
            $s->execute();$s->closeCursor();
        }
    } finally {p177Release($s);}
    $metadata=p177Read($db,'SELECT id,title,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS is_missing,DATALENGTH(payload) AS byte_count FROM '.$t.' ORDER BY id');
    $states=[];
    foreach ($metadata as $row) {
        $states[]=(int)$row['is_missing']===1?'NULL':((int)$row['byte_count']===0?'EMPTY':'FILE:'.$row['byte_count']);
    }
    $s=$db->prepare('SELECT payload FROM '.$t.' WHERE id=?');$received=null;
    try {
        $s->bindValue(1,1,PDO::PARAM_INT);$s->execute();
        $s->bindColumn(1,$received,PDO::PARAM_STR,0,PDO::SQLSRV_ENCODING_BINARY);
        if ($s->fetch(PDO::FETCH_BOUND)!==true || !is_string($received)) {throw new RuntimeException('Вложение не получено как бинарная строка.');}
        $readType=get_debug_type($received);$bytes=strlen($received);$digest=hash('sha256',$received);
    } finally {p177Release($s);}
    if (file_put_contents(__DIR__.'/attachment.bin',$received)!==$bytes) {throw new RuntimeException('Файл записан не полностью.');}
    $rows=[['Вложение',$metadata[0]['title']],['Байт получено',$bytes],['HEX',bin2hex($received)],
        ['Содержимое совпало с источником',$received===$payload],['Три состояния',implode('; ',$states)],['Файл','attachment.bin']];
    $checks=['binary_exact'=>$received===$payload,'binary_contains_nul_ff'=>strpos($received,"\0\xFF")!==false,
        'received_string'=>$readType==='string','metadata_size'=>(int)$metadata[0]['byte_count']===$bytes,
        'empty_distinct'=>(int)$metadata[1]['is_missing']===0 && (int)$metadata[1]['byte_count']===0,
        'sql_null_distinct'=>(int)$metadata[2]['is_missing']===1 && $metadata[2]['byte_count']===null,
        'file_hash'=>hash_file('sha256',__DIR__.'/attachment.bin')===$digest,
        'artifact_written'=>is_file(__DIR__.'/attachment.bin')];
    $observations=['selected_metadata'=>$metadata,'states'=>$states,'input_after_execute'=>$inputTypeAfter,
        'received_type'=>$readType,'bytes'=>$bytes,'sha256'=>$digest,'hex'=>bin2hex($received),
        'server_streaming_fetch_claim'=>false];

} finally {

    if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}

p177Report('pdo177-binary-attachment','Скачать одно бинарное вложение и отличить пустой файл от отсутствующего',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Вложение — Акт монтажа
Байт получено — 21
HEX — 50444f31373700ff0d0a4154544143484d454e540a
Содержимое совпало с источником — да
Три состояния — FILE:21; EMPTY; NULL
Файл — attachment.bin

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать фактическое бинарное вложение

Границы решения

Этот CLI-проект создаёт файл; он не притворяется HTTP-загрузкой с проверенными заголовками. В рабочем download endpoint отдельно задают MIME/Content-Disposition и проверяют права. Полный BLOB уже находится в памяти строки; не объявляйте это ленивым серверным потоком. Размер известного учебного файла ограничен заранее. При работе с чужими файлами лимит задают до загрузки содержимого.

Попробуйте изменить условие

Можно ли заменить строгие сравнения на if (!$bytes), чтобы одним условием ловить все пустые состояния?

Разбор

Нет. Так потеряются пустой файл, строка "0" и разные причины отсутствия значения. Сначала различите отсутствие записи через ===false, затем SQL NULL через ===null, затем разрешите или запретите strlen===0 по бизнес-правилу. Нулевой байт внутри непустой строки вообще не является концом PHP-строки.

Курсор ограниченного SQL-результата

Экрану нужны три товара со второй страницы, а не все десятки тысяч строк. Сначала SQL выбирает категорию, порядок и диапазон; только затем решается, нужно ли двигаться назад внутри уже выбранных трёх строк. Буферизация и SQL-пагинация решают разные задачи.

Окно каталога
  1. WHERE category=электрика → шесть подходящих товаров
  2. ORDER BY price_cents,id → одинаковые цены имеют устойчивый порядок
  3. OFFSET2/FETCH3 → Кабель, Розетка, Выключатель
  4. Курсор работает только с этим окном; COUNT отдельного запроса остаётся 6
$statement = $db->prepare(
    'SELECT id,title,price_cents FROM PRODUCTS WHERE category=?
     ORDER BY price_cents,id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY'
);
$statement->bindValue(1, 'электрика', PDO::PARAM_STR);
$statement->bindValue(2, 2, PDO::PARAM_INT);
$statement->bindValue(3, 3, PDO::PARAM_INT);
$statement->execute();
while (($product=$statement->fetch(PDO::FETCH_ASSOC)) !== false) {
    echo $product['title'], PHP_EOL;
}
$statement->closeCursor();

OFFSET пропускает первые две строки выбранного порядка, FETCH NEXT ограничивает результат тремя. Позиции и размер страницы связаны как целые числа. foreach/fetch не сортирует и не фильтрует всю таблицу в PHP. Если данные меняются между HTTP-запросами, OFFSET-страницы могут сдвигаться; для стабильного продолжения больших списков рассматривают выборку по последнему ключу и подходящий индекс.

Атрибут/режимЧто поддерживает и сколько хранит
PDO::ATTR_CURSOR=PDO::CURSOR_FWDONLYПо умолчанию; чтение вперёд. rowCount SELECT не является общим способом узнать размер
PDO::ATTR_CURSOR=PDO::CURSOR_SCROLLРазрешает ориентации; без отдельного SQLSRV scroll type используется STATIC
SQLSRV_CURSOR_STATICСерверный снимок результата; дальнейшие изменения строк не становятся автоматически видны
SQLSRV_CURSOR_DYNAMICСерверный динамический набор; видимость и поддержка конкретной ориентации ограничены SQL Server/ODBC
SQLSRV_CURSOR_KEYSETНабор ключей; изменения значений и удаления отличаются от появления новых строк; число строк не равнозначно числу ещё доступных записей
SQLSRV_CURSOR_BUFFEREDПолный выбранный результат хранится у клиента; удобен для небольшого окна и позиционного доступа
SQLSRV_ATTR_CURSOR_SCROLL_TYPEОдин из четырёх SQLSRV_CURSOR_* задаётся при prepare вместе с CURSOR_SCROLL; с FWDONLY это ошибка
SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZEПоложительное int число КБ; default 10240. Connection/statement и ini pdo_sqlsrv.client_buffer_max_kb_size задают предел клиентского буфера, не SQL LIMIT и не весь memory_limit

Для второго применения, печати небольшой выбранной страницы в другом порядке просмотра, настройте buffered/static и fetch с PDO::FETCH_ORI_FIRST, LAST, PRIOR, NEXT, REL или ABS, если конкретный курсор это допускает. В проверенном PDO_SQLSRV ABS использует индекс с нуля: 0 — первая строка, 1 — вторая. Не переносите сюда нумерацию строк из ODBC API. Не путайте ABS внутри результата с OFFSET в SQL. Переход за доступные строки возвращает false; ошибка при ERRMODE_EXCEPTION бросает исключение. В полном проекте окна сравниваются реальные rowCount и выбранные товары, затем малый буфер отказывает широкой выборке и успешно принимает узкую.

В подготовке forward-only вернул rowCount=-1 до обхода, но после полного fetch число стало равно прочитанным двум строкам. Поэтому утверждение «forward всегда даёт -1» неверно. Проект намеренно фиксирует значение до чтения; ни это число, ни count страницы не отвечают на вопрос, сколько всего товаров подходит категории. Для этого нужен отдельный COUNT с тем же WHERE. При превышении буфера PDO_SQLSRV 5.13.3 сообщил SQLSTATE IMSSP и код -71; native sqlsrv_* имеет другой диагностический договор.

Обсуждение превышения 10240 КБ буфера началось с большой HTML-таблицы. Ответ «увеличить лимит» не определяет объём, который нужен экрану. Здесь исправляется форма запроса: выбранные колонки и три строки без большого payload. Это не замер ускорения и не обещание, что произвольные три строки всегда помещаются в один КБ.

closeCursor освобождает результаты, но не откатывает транзакцию. В PDO_SQLSRV он может прочитать оставшиеся результаты команды и встретить позднюю ошибку. Как завершать batch, получить OUTPUT и работать с MultipleActiveResultSets, объясняет соседняя глава. Метаданные getColumnMeta относятся к результату запроса, а не заменяют аудит схемы: alias, вычисляемое выражение и настройки PHP-представления нужно учитывать отдельно.

Показать три товара и ограничить память курсора

Экран каталога показывает три товара из шести подходящих. Пользователь может перейти к последней строке именно этого окна; общий размер категории рассчитывает отдельный COUNT. Программа сравнивает forward-only, клиентский buffered и серверный static, затем получает реальный отказ для ненужного широкого payload и успешно повторяет узкую страницу в том же бюджете.

Путь данных и полученный результат
  1. Категория: 6 товаров; порядок по цене 50, 100, 150, 200, 250, 300
  2. SQL OFFSET 2 / FETCH 3 → Кабель 150; Розетка 200; Выключатель 250
  3. Buffered LAST → Выключатель, ABS 1 → Розетка; COUNT категории = 6
  4. Широкий payload не помещается в 1 КБ; три нужные строки без payload помещаются

Минимальный вызов

$statement=$db->prepare(
    'SELECT id,title FROM PRODUCTS ORDER BY id OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY',
    [PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL,
     PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE=>PDO::SQLSRV_CURSOR_BUFFERED]
);
$statement->execute();
$last=$statement->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_LAST);
// Последняя строка только выбранного окна, не всей таблицы.
$statement->closeCursor();

CURSOR_SCROLL разрешает поддержанные направления чтения; SQLSRV_CURSOR_BUFFERED выбирает клиентское хранение результата. FETCH_ORI_LAST перемещает внутри этого результата. Полный проект сначала связывает OFFSET=2 и FETCH=3 как PARAM_INT, затем проверяет LAST/FIRST/ABS и повторное execute. rowCount каждого типа фиксируется до обхода; количество категории берётся SQL COUNT, а не догадкой по курсору.

Что получится после запуска

НаблюдениеРезультат
Товары страницыКабель; Розетка; Выключатель
Строк на странице3
Всего в категории6
rowCount: forward / buffered / static-1 / 3 / 3
Последняя строка окнаВыключатель
Вторая строка через ABSРозетка
Широкая выборка при 1 КБотказ; узкая страница снова получена
Файлwindow.csv
Самостоятельный проект, исходник и архив
Показать три товара и ограничить память курсораСамостоятельный пример
<?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);

function writeCsv177(string $name,array $records): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('CSV не открыт.');}
    try {
        foreach ($records as $record) {
            if (fputcsv($file,$record,';','"','',"\n")===false) {throw new RuntimeException('CSV записан не полностью.');}
        }
    } finally {fclose($file);}
}

function pageRequest177(PDO $db,string $sql,array $options): PDOStatement {
    $s=$db->prepare($sql,$options);
    $s->bindValue(1,'электрика',PDO::PARAM_STR);
    $s->bindValue(2,2,PDO::PARAM_INT);$s->bindValue(3,3,PDO::PARAM_INT);
    $s->execute();return $s;
}
    $name=p177OwnTable($db,$owned,'window','(id int PRIMARY KEY,title nvarchar(80) NOT NULL,category nvarchar(32) NOT NULL,price_cents int NOT NULL,payload varchar(4000) NOT NULL)');
    $t=p177Qualified($name);
    foreach ([[1,'Провод',100],[2,'Розетка',200],[3,'Кабель',150],[4,'Выключатель',250],[5,'Хомут',50],[6,'Переходник',300],[7,'Кисть',40],[8,'Валик',500]] as $r) {
        p177Command($db,'INSERT INTO '.$t.' (id,title,category,price_cents,payload) VALUES (?,?,?,?,?)',
            [$r[0],$r[1],$r[0]<=6?'электрика':'отделка',$r[2],str_repeat('x',4000)]);
    }
    $sql='SELECT id,title,price_cents FROM '.$t.' WHERE category=? ORDER BY price_cents,id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY';
    $s=null;$forward=$buffered=$static=[];
    try {
        $s=pageRequest177($db,$sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_FWDONLY]);
        $forwardCount=$s->rowCount();$forward=p177Rows($s);p177Release($s);
        $s=pageRequest177($db,$sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL,
            PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE=>PDO::SQLSRV_CURSOR_BUFFERED,
            PDO::SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE=>1]);
        $bufferedCount=$s->rowCount();
        $last=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_LAST);
        $first=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_FIRST);
        $absolute=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_ABS,1);
        $s->execute();$buffered=p177Rows($s);p177Release($s);
        $s=pageRequest177($db,$sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL,
            PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE=>PDO::SQLSRV_CURSOR_STATIC]);
        $staticCount=$s->rowCount();$static=p177Rows($s);p177Release($s);
    } finally {p177Release($s);}
    $count=(int)p177Read($db,'SELECT COUNT(*) AS total FROM '.$t.' WHERE category=?',['электрика'])[0]['total'];
    $wide=null;
    try {
        $wide=$db->prepare('SELECT id,payload FROM '.$t.' ORDER BY id',[
            PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL,
            PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE=>PDO::SQLSRV_CURSOR_BUFFERED,
            PDO::SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE=>1]);
        $failure=p177Capture(static function() use($wide): void {$wide->execute();});
    } finally {p177Release($wide);}
    $s=null;
    try {
        $s=pageRequest177($db,$sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL,
            PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE=>PDO::SQLSRV_CURSOR_BUFFERED,
            PDO::SQLSRV_ATTR_CLIENT_BUFFER_MAX_KB_SIZE=>1]);
        $afterFailure=p177Rows($s);
    } finally {p177Release($s);}
    $csv=[['id','title','price_cents']];foreach($buffered as $r) {$csv[]=[(int)$r['id'],$r['title'],(int)$r['price_cents']];}
    writeCsv177('window.csv',$csv);
    $rows=[['Товары страницы',implode('; ',array_column($buffered,'title'))],['Строк на странице',count($buffered)],
        ['Всего в категории',$count],['rowCount: forward / buffered / static',$forwardCount.' / '.$bufferedCount.' / '.$staticCount],
        ['Последняя строка окна',$last['title']],['Вторая строка через ABS',$absolute['title']],
        ['Широкая выборка при 1 КБ','отказ; узкая страница снова получена'],['Файл','window.csv']];
    $checks=['selected_page'=>array_map('intval',array_column($buffered,'id'))===[3,2,4],
        'same_three_cursors'=>$forward===$buffered && $static===$buffered,
        'forward_unknown_count'=>$forwardCount===-1,'scroll_count'=>$bufferedCount===3&&$staticCount===3,
        'last_and_first'=>(int)$last['id']===4&&(int)$first['id']===3,'absolute_second'=>(int)$absolute['id']===2,
        'count_is_not_page'=>$count===6&&count($buffered)===3,
        'wide_budget_failed'=>$failure['failed']===true && $failure['state']==='IMSSP' && $failure['code']===-71,
        'narrow_after_failure'=>$afterFailure===$buffered,'artifact_written'=>is_file(__DIR__.'/window.csv')];
    $observations=['selected_rows'=>$buffered,'row_count'=>['forward'=>$forwardCount,'buffered'=>$bufferedCount,'static'=>$staticCount],
        'orientation_rows'=>['last'=>$last,'first'=>$first,'absolute_1'=>$absolute],'matching_category_count'=>$count,
        'wide_selection_diagnostics'=>$failure,'buffer_budget_kb'=>1,'narrow_after_failure'=>$afterFailure];

} finally {

    if ($db instanceof PDO) {try {$removed=p177Cleanup($db,$owned);} finally {p177Close($db);}}
}

p177Report('pdo177-cursor-window','Показать три товара и ограничить память курсора',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Товары страницы — Кабель; Розетка; Выключатель
Строк на странице — 3
Всего в категории — 6
rowCount: forward / buffered / static — -1 / 3 / 3
Последняя строка окна — Выключатель
Вторая строка через ABS — Розетка
Широкая выборка при 1 КБ — отказ; узкая страница снова получена
Файл — window.csv

Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.

Открыть результат выполнения · Посмотреть данные JSON · Скачать выбранную страницу каталога

Границы решения

Буферный предел относится к выбранным данным и внутреннему представлению драйвера. Три огромных поля тоже могут превысить его. Этот пример не измеряет скорость и не меняет глобальные INI/серверные параметры. На больших последовательных выгрузках достаточно forward-only; для независимых HTTP-страниц нужно снова выполнить ограниченный SQL, а не хранить cursor между запросами пользователя.

Попробуйте изменить условие

Нужна следующая страница того же размера. Нужно ли загрузить все товары или увеличить клиентский буфер?

Разбор

Нет. Для неизменного набора следующий OFFSET равен5, FETCH остаётся3; из этой категории вернётся только Переходник. На живом изменяемом каталоге продумайте выборку по последнему ключу (price_cents,id) и индекс: один устойчивый ORDER BY не запрещает конкурентные изменения.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.