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

Дерево руководства
Глава 299 · После подключения PDO_ODBC и параметризованного SELECT
PDO_ODBC: точная выгрузка, проверка колонок и нужная страница результата
Ведомость с точными цифрами, проверка заголовков, три выбранных товара и восстановленный файл: реальные контракты PDO_ODBC.
Сохранить смысл выбранных данных
Пользователь ждёт конкретный результат: три товара на экране, файл проводок или сохранённое вложение. Для этого недостаточно получить «какой-то массив». Нужно договориться, какие поля пришли, в каком порядке, как представлены деньги и что означает пустое значение. Здесь вы получите четыре самостоятельных проекта и четыре полезных файла: ledger.csv, column-contract.json, page.csv и attachment.bin.
- SQL: выбрать день или категорию, поля и порядок
- ODBC-driver: получить SQL-значения и курсор
- PDO_ODBC: представить значения в PHP
- Приложение: проверить договор и записать файл для человека
Модель похожа на отправку ведомости: база выбирает нужные строки, драйвер определяет способ передачи, а программа подписывает колонки и сохраняет документ. Это не буквальное описание сетевых пакетов или гарантия потокового чтения. Один и тот же PDO-метод может давать разные типы и курсорные позиции у разных драйверов.
PDO связывает PHP-приложение с базой: объект соединения создаёт команды, а PDOStatement позволяет получить их результат. PDO_ODBC передаёт работу менеджеру ODBC и выбранному драйверу базы. Поэтому одинаковые методы PDO не делают SQL, типы, курсоры и ошибки всех серверов одинаковыми.
Самостоятельные проекты используют PDO_ODBC из PHP 8.5.11 для Windows, Microsoft ODBC Driver 18 и отдельный SQL Server LocalDB. Это конкретный проверяемый маршрут; другие серверы требуют своего драйвера и проверки. Начало SQL и PDO объясняет таблицу, запрос и получение одной строки, а глава подключения PDO_ODBC — слои маршрута и подготовку окружения.
Сначала пройдите подключение PDO_ODBC и передачу параметров. Для этой главы нужны уже знакомые массивы, обход строк в цикле и исключения. Новая часть — договор результата: в примере FETCH_ASSOC ключ amount обозначает выбранную колонку, а значение остаётся точной строкой. Номер документа из БД не превращается в число только потому, что состоит из цифр.
| Задача | Вход из SQL | Полезный итог |
|---|---|---|
| Проводки дня | document, booked_on, amount, note | Все цифры большого ID; NULL/EMPTY/VALUE и точный итог |
| Карта экспорта | sku AS item_code, title AS item_title, qty AS available | Две строки каталога и отказ при неверном alias |
| Страница каталога | WHERE category, ORDER BY price,id, OFFSET/FETCH | ID 2, 3, 4; общий счётчик 6 |
| Вложение | Одна VARBINARY-строка по ID | Восстановленный файл с теми же NUL/FF байтами |
Условия задаются до получения данных: WHERE ограничивает набор, перечисление колонок исключает ненужные поля, ORDER BY фиксирует порядок. Получить сотни тысяч полных записей через SELECT *, затем сортировать или фильтровать большой PHP-массив — значит потратить сеть и память на данные, которых пользователь не увидит. Небольшие массивы в этих проектах хранят только уже ограниченный результат; они не заменяют работу запроса.
Точные цифры и пустые состояния
Для сверки платежей нужен номер 9007199254740993 и сумма 1234567890123456.78 без изменения последней цифры. SQL BIGINT и DECIMAL описывают хранение в базе; PHP string/int/float описывают уже полученное значение. Совпадения названий и ожидаемого вида здесь недостаточно.
В проверенной связке PHP 8.5.11, PDO_ODBC и Microsoft ODBC Driver 18 значения INT, BIT, BIGINT, DECIMAL и DATE получены как строки; SQL NULL остаётся null, пустой текст — пустой строкой. Это наблюдение данного пути, а не обещание всех ODBC-драйверов. В частности, точный десятичный тип не нужно самостоятельно превращать во float, чтобы «исправить строку».
| Значение | Зачем сохранить различие | Прикладная обработка |
|---|---|---|
| BIGINT 9007199254740993 | Число за границей точных целых IEEE-754 double | Идентификатор хранить/выгружать строкой; не вычислять с ним |
| DECIMAL(18,2) 1234567890123456.78 | Два знака после точки — часть договора суммы | Суммировать в SQL DECIMAL либо десятичной библиотекой |
| .00 / -.10 | ODBC может опустить ведущий ноль | В текстовом экспорте добавить 0 без float |
| NULL / пустая строка / "0" | Нет пометки / намеренно пусто / конкретное значение | Сравнения === null и === ""; в файле отдельное note_state |
| DATE 2026-11-08 | Дата не содержит часов и часового пояса | Выбирать диапазон по DATE, не выдумывать DateTime-атрибут SQLSRV |
$s = $db->prepare('SELECT CAST(SUM(amount) AS decimal(18,2)) AS total FROM ledger WHERE booked_on >= ? AND booked_on < ?');
$s->execute(['2026-11-08', '2026-11-09']);
$total = $s->fetchColumn();
$s->closeCursor();
// Для набора проекта: строка "1234567890123456.68".Это второе применение точного представления: первая выборка формирует ведомость, а агрегат возвращает итог одной строкой без пересылки остальных записей. Полуоткрытый диапазон включает начало дня и исключает следующее начало. Если подходящих строк нет, SUM вернёт SQL NULL; решите явно, выводить «нет операций» или заменять итог нулём. Полный проект проводок использует непустой набор и отдельно проверяет каждое состояние пометки.
CSV не задаёт SQL-типы. При открытии в табличной программе импортируйте длинный document как текст: кавычки CSV сами по себе не запрещают автоматическое распознавание. Для произвольного пользовательского текста нужна политика защиты от формул в целевом табличном приложении; учебные значения фиксированы. Метод PDOStatement::fetch и формы результата подробно разобраны в справочнике, а PDO_SQLSRV показывает другой драйверный договор на том же сервере.
Выгрузить проводки дня без потери точности и пустых состояний
Бухгалтерии нужны проводки одного дня. Номер документа должен остаться тем же набором цифр, сумма — точным десятичным значением, а отсутствие пометки не должно превратиться в строку «0». Получится CSV с тремя записями и отдельный итог, вычисленный базой.
- 4 проводки: 3 за 8 ноября, 1 за 9 ноября
- WHERE booked_on >= 8 и < 9 ноября; ORDER BY дата,id
- BIGINT и DECIMAL сохраняются текстом; NULL → отдельный note_state
- ledger.csv: 3 строки; SQL SUM → 1234567890123456.68
Минимальный вызов
$s = $db->prepare('SELECT document, amount FROM ledger WHERE id = ?');
$s->execute([1]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row !== false) {
echo $row['document'], ' | ', $row['amount'], "\n";
}
$s->closeCursor();
// Для данных полного проекта: 9007199254740993 | 1234567890123456.78ledger в коротком фрагменте — уже созданная таблица полного проекта. prepare сохраняет SQL с местом ? для значения; execute([1]) подставляет значение через драйвер. FETCH_ASSOC возвращает строку как массив с именами document и amount. Проверка !== false отличает найденную запись от конца результата. Мы выводим полученные строки и не превращаем точные цифры во float. Полный проект создаёт свою таблицу, добавляет данные и удаляет её после выгрузки.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Период | 2026-11-08, до 2026-11-09 не включая |
| Документ 1 | 9007199254740993 | 1234567890123456.78 | NULL |
| Документ 2 | 9007199254740994 | -0.10 | EMPTY |
| Документ 3 | 9007199254740995 | 0.00 | VALUE: 0 |
| Итог SQL | 1234567890123456.68 |
| Файл | ledger.csv: заголовок и 3 проводки |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
function writeCsv178(string $filename,array $records): void {
$file=fopen(__DIR__.'/'.$filename,'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 decimalText178(string $value): string {
// У некоторых ODBC-представлений перед десятичной точкой нет нуля.
if (str_starts_with($value,'-.')) {$value='-0'.substr($value,1);}
elseif (str_starts_with($value,'.')) {$value='0'.$value;}
if (!preg_match('/^-?[0-9]+\.[0-9]{2}$/D',$value)) {throw new UnexpectedValueException('Нужна десятичная строка с двумя знаками.');}
return $value;
}
$name=p178OwnTable($db,$owned,'ledger','(id int PRIMARY KEY,document bigint NOT NULL,booked_on date NOT NULL,amount decimal(18,2) NOT NULL,note nvarchar(40) NULL)');
$t=p178Qualified($name);
foreach ([
[1,'9007199254740993','2026-11-08','1234567890123456.78',null],
[2,'9007199254740994','2026-11-08','-0.10',''],
[3,'9007199254740995','2026-11-08','0.00','0'],
[4,'9007199254740996','2026-11-09','99.99','Завтра'],
] as $record) {p178Command($db,'INSERT INTO '.$t.' (id,document,booked_on,amount,note) VALUES (?,?,?,?,?)',$record);}
$from='2026-11-08';$until='2026-11-09';$s=null;$data=[];
$records=[['id','document','day','amount','note_state','note']];
try {
$s=$db->prepare('SELECT id,document,booked_on,amount,note FROM '.$t.' WHERE booked_on>=? AND booked_on<? ORDER BY booked_on,id');
$s->execute([$from,$until]);
while (($row=$s->fetch(PDO::FETCH_ASSOC))!==false) {
if (!is_string($row['document']) || !is_string($row['amount'])) {throw new UnexpectedValueException('Числовой контракт драйвера изменился.');}
$state=$row['note']===null?'NULL':($row['note']===''?'EMPTY':'VALUE');
$records[]=[(string)$row['id'],$row['document'],(string)$row['booked_on'],decimalText178($row['amount']),$state,$row['note']??''];
$data[]=$row;
}
} finally {p178Release($s);}
$sum=p178Read($db,'SELECT CAST(SUM(amount) AS decimal(18,2)) AS total FROM '.$t.' WHERE booked_on>=? AND booked_on<?',[$from,$until])[0]['total'];
$total=decimalText178($sum);
writeCsv178('ledger.csv',$records);
$checks=[
'selected_three'=>count($data)===3 && array_column($records,0)===['id','1','2','3'],
'large_identifier_exact'=>$records[1][1]==='9007199254740993',
'decimal_exact'=>$records[1][3]==='1234567890123456.78' && $records[2][3]==='-0.10' && $records[3][3]==='0.00',
'sum_in_sql'=>$total==='1234567890123456.68',
'null_empty_zero'=>$data[0]['note']===null && $data[1]['note']==='' && $data[2]['note']==='0',
'file_written'=>is_file(__DIR__.'/ledger.csv') && filesize(__DIR__.'/ledger.csv')>0,
];
$rows=[['Период','2026-11-08, до 2026-11-09 не включая'],
['Документ 1',$records[1][1].' | '.$records[1][3].' | NULL'],
['Документ 2',$records[2][1].' | '.$records[2][3].' | EMPTY'],
['Документ 3',$records[3][1].' | '.$records[3][3].' | VALUE: 0'],
['Итог SQL',$total],['Файл','ledger.csv: заголовок и 3 проводки']];
$observations=['selected_rows'=>$data,'csv_records'=>$records,'sum'=>$total,
'php_types'=>array_map(static fn(array $r): array=>array_map('get_debug_type',$r),$data)];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-exact-export','Выгрузить проводки дня без потери точности и пустых состояний',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Период — 2026-11-08, до 2026-11-09 не включая
Документ 1 — 9007199254740993 | 1234567890123456.78 | NULL
Документ 2 — 9007199254740994 | -0.10 | EMPTY
Документ 3 — 9007199254740995 | 0.00 | VALUE: 0
Итог SQL — 1234567890123456.68
Файл — ledger.csv: заголовок и 3 проводки
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать проводки ledger.csv
Границы решения
Нулевой знак перед точкой нормализуется текстом: .00 → 0.00, без пересчёта через float. Это формат DECIMAL(18,2), а не универсальный парсер чисел всех локалей. Итог SQL и выборка — два последовательных чтения; для согласованного снимка при конкурентных изменениях нужна отдельная политика транзакции. CSV хранит цифры как текст файла, но табличный редактор может сам преобразовать их при открытии: импортируйте document как текст. В реальном сервисе задайте предел периода/страницы; здесь массив содержит только три заранее ограниченные строки.
Попробуйте изменить условие
Добавьте проводку -0.20 за 8 ноября. Какие поля CSV и итог должны измениться? Должна ли запись за 9 ноября попасть в отчёт?
Разбор
В CSV появится четвёртая запись выбранного дня; итог станет 1234567890123456.48. Номер и сумма остаются строками. Запись за 9 ноября по-прежнему исключена полуоткрытым диапазоном. Не делайте итог через array_sum строк, преобразующий точные суммы в обычную арифметику PHP.
Описание колонок и договор экспорта
Допустим, потребитель файла ждёт item_code, item_title, available. Ему важно получить именно эти имена, а не угадать их из первой непустой строки. Метаданные описывают колонки выполненной команды и помогают проверить заголовки даже тогда, когда WHERE не нашёл ни одного товара.
$count = $s->columnCount();
$meta = $s->getColumnMeta(0); // первая колонка, а не строка 0
if ($meta === false) {
throw new RuntimeException('Нет описания первой колонки.');
}
echo $meta['name'], "\n"; // item_code в проектеcolumnCount() возвращает число колонок текущего результата. getColumnMeta(int $column): array|false принимает индекс от 0 до columnCount()-1; возвращает ассоциативное описание, а для отсутствующей колонки/результата контракт предусматривает false. Ошибки драйвера при ERRMODE_EXCEPTION надо обрабатывать как PDOException. Ни одно из этих действий не читает следующую строку fetch.
| Поле общего PDO-контракта | Смысл | В проверенном PDO_ODBC |
|---|---|---|
| name | Имя выбранной колонки, включая SQL alias | Есть: например item_code |
| pdo_type | Код PDO::PARAM_* для получаемого представления | Есть; у проверенных колонок PARAM_STR (2) |
| len | Длина, сообщаемая описанием колонки | Есть; не фактическая длина каждой строки |
| precision | Точность из метаданных | Есть; в подготовке 0, не шкала DECIMAL(18,2) |
| native_type | Информация о типе представления от драйвера | Отсутствует |
| driver:decl_type | Описание объявленного SQL-типа при наличии | Отсутствует |
| flags | Флаги колонки | Отсутствуют |
| table | Имя исходной таблицы | Отсутствует |
Общий PHP Manual перечисляет возможные поля; их набор зависит от драйвера. В исходнике PDO_ODBC 8.5.11 драйвер добавляет pdo_type, остальные наблюдённые name/len/precision поступают из общего механизма PDO. Поэтому нельзя написать $meta["native_type"] без проверки и объявить поддержку метода сломанной. Получить описание SQL-схемы при необходимости можно отдельным серверным каталогом, но результат вычисляемого SELECT и таблица хранения — разные предметы.
Вопрос о пропавшем native_type описывает именно эту потребность: приложение хотело вывести SQL-типы через общий метод. Наше решение не угадывает недоступную информацию: оно хранит явную карту назначения полей и сверяет имена результата.
Два применения показаны в проекте карты: обычная выгрузка с двумя товарами и пустая категория с тем же заголовком. Третий контролируемый ход меняет alias item_title на old_title: программа отказывает до записи итогового файла. Это прикладная проверка через DomainException, а не вымышленная ошибка ODBC. Справочные детали: PDOStatement::getColumnMeta и PDOStatement::columnCount.
Проверить колонки экспорта до записи файла
Внешняя программа ждёт артикул, название и остаток именно в таком порядке. После изменения SELECT колонка title может получить другой alias, и импорт начнёт молча читать неверный формат. Проект проверяет структуру до записи, сохраняет две полезные строки каталога и отдельно показывает, что пустая выборка всё равно имеет описание колонок.
- Карта экспорта: item_code → item_title → available
- SELECT возвращает эти alias в таком же порядке
- getColumnMeta сверяет заголовки до fetch, даже для 0 строк
- column-contract.json содержит карту и Кабель 3 / Хомут 0
Минимальный вызов
$s = $db->prepare('SELECT sku AS item_code, qty AS available FROM items WHERE id = ?');
$s->execute([1]);
$first = $s->getColumnMeta(0);
echo $first['name'], "\n"; // item_code
$s->closeCursor();getColumnMeta(0) относится к первой выбранной колонке: нумерация начинается с нуля. AS задаёт имя в результате, не переименовывая поле таблицы. Массив metadata может содержать только часть документированных характеристик. Полный проект сначала сверяет имена с явной картой columns, затем получает строки через fetch; проверку вида значения задаёт прикладной договор, а не предположение по имени SQL-типа.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Колонки | item_code, item_title, available |
| Позиция A-10 | Кабель | 3 |
| Позиция B-20 | Хомут | 0 |
| Неверный alias | old_title: экспорт отклонён |
| Пустая категория | 0 строк; те же 3 колонки |
| SQL-тип из metadata | native_type отсутствует; формат задан картой |
| Файл | column-contract.json |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
function selectedNames178(PDOStatement $s): array {
$names=[];$meta=[];
for ($i=0;$i<$s->columnCount();$i++) {
$item=$s->getColumnMeta($i);
if ($item===false || !isset($item['name'])) {throw new UnexpectedValueException('Драйвер не сообщил имя выбранной колонки.');}
$names[]=$item['name'];$meta[]=$item;
}
return [$names,$meta];
}
function requireNames178(array $actual,array $expected): void {
if ($actual!==$expected) {throw new DomainException('Имена или порядок колонок не совпали с контрактом экспорта.');}
}
$name=p178OwnTable($db,$owned,'schema','(id int PRIMARY KEY,sku varchar(20) NOT NULL,title nvarchar(60) NOT NULL,category varchar(20) NOT NULL,qty int NOT NULL)');
$t=p178Qualified($name);
foreach ([[1,'A-10','Кабель','wiring',3],[2,'B-20','Хомут','wiring',0],[3,'C-30','Кисть','paint',12]] as $record) {
p178Command($db,'INSERT INTO '.$t.' (id,sku,title,category,qty) VALUES (?,?,?,?,?)',$record);
}
$columns=[
['name'=>'item_code','label'=>'Артикул','kind'=>'text'],
['name'=>'item_title','label'=>'Название','kind'=>'text'],
['name'=>'available','label'=>'Остаток','kind'=>'integer_text'],
];
$expected=array_column($columns,'name');$s=null;$selected=[];$empty=[];$meta=[];$wrong=false;$badNames=[];
try {
$s=$db->prepare('SELECT sku AS item_code,title AS item_title,qty AS available FROM '.$t.' WHERE category=? ORDER BY id');
$s->execute(['wiring']);
[$names,$meta]=selectedNames178($s);requireNames178($names,$expected);
while (($row=$s->fetch(PDO::FETCH_ASSOC))!==false) {$selected[]=$row;}
} finally {p178Release($s);}
try {
$s=$db->prepare('SELECT sku AS item_code,title AS old_title,qty AS available FROM '.$t.' WHERE category=? ORDER BY id');
$s->execute(['wiring']);[$badNames,$ignored]=selectedNames178($s);
try {requireNames178($badNames,$expected);} catch (DomainException $e) {$wrong=true;}
} finally {p178Release($s);}
try {
$s=$db->prepare('SELECT sku AS item_code,title AS item_title,qty AS available FROM '.$t.' WHERE category=? ORDER BY id');
$s->execute(['missing']);[$emptyNames,$emptyMeta]=selectedNames178($s);requireNames178($emptyNames,$expected);
while (($row=$s->fetch(PDO::FETCH_ASSOC))!==false) {$empty[]=$row;}
} finally {p178Release($s);}
$hasNative=false;foreach ($meta as $m) {$hasNative=$hasNative || array_key_exists('native_type',$m);}
$contract=['columns'=>$columns,'rows'=>$selected,'empty_rows'=>$empty,'wrong_alias_rejected'=>$wrong,'native_type_available'=>$hasNative];
$json=json_encode($contract,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR)."\n";
if (file_put_contents(__DIR__.'/column-contract.json',$json)!==strlen($json)) {throw new RuntimeException('JSON записан не полностью.');}
$checks=[
'names_match'=>$names===$expected,'selected_rows'=>$selected===[['item_code'=>'A-10','item_title'=>'Кабель','available'=>'3'],['item_code'=>'B-20','item_title'=>'Хомут','available'=>'0']],
'wrong_alias_rejected'=>$wrong && $badNames===['item_code','old_title','available'],
'empty_schema_preserved'=>$empty===[] && $emptyNames===$expected,
'metadata_not_sql_type'=>!$hasNative && array_column($meta,'pdo_type')===[PDO::PARAM_STR,PDO::PARAM_STR,PDO::PARAM_STR],
'json_written'=>json_decode(file_get_contents(__DIR__.'/column-contract.json'),true,512,JSON_THROW_ON_ERROR)===$contract,
];
$rows=[['Колонки',implode(', ',$names)],['Позиция A-10',$selected[0]['item_title'].' | '.$selected[0]['available']],
['Позиция B-20',$selected[1]['item_title'].' | '.$selected[1]['available']],['Неверный alias','old_title: экспорт отклонён'],
['Пустая категория','0 строк; те же 3 колонки'],['SQL-тип из metadata','native_type отсутствует; формат задан картой'],['Файл','column-contract.json']];
$observations=['metadata'=>$meta,'selected'=>$selected,'wrong_names'=>$badNames,'empty_names'=>$emptyNames,'contract'=>$contract];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-export-schema','Проверить колонки экспорта до записи файла',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Колонки — item_code, item_title, available
Позиция A-10 — Кабель | 3
Позиция B-20 — Хомут | 0
Неверный alias — old_title: экспорт отклонён
Пустая категория — 0 строк; те же 3 колонки
SQL-тип из metadata — native_type отсутствует; формат задан картой
Файл — column-contract.json
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать карту колонок и выбранные товары
Границы решения
Отсутствующий native_type не означает, что в базе все колонки VARCHAR. pdo_type описывает выдачу через PDO; карта экспорта отдельно определяет назначение поля. Метаданные результата не заменяют каталог схемы, правила домена и проверку прав. Имена/SQL здесь выбирает автор; неизвестное имя из формы нельзя подставлять в SQL по факту наличия в metadata. Артефакт сохраняет небольшую выбранную страницу, а не все товары магазина.
Попробуйте изменить условие
Добавьте total_cents после available. Что нужно изменить, чтобы экспортёр принял новую схему, но по-прежнему отказывал ошибочному порядку?
Разбор
Добавьте вычисление или поле в SELECT и соответствующую запись в columns на той же позиции. Обновите договор потребителя. Сравнивайте целые списки имён в порядке, а не только наличие каждого имени. Количество/типы строк для тестового набора обновляются вместе с ожидаемым файлом.
Позиция курсора и размер SQL-страницы
Курсор — текущее положение чтения в результате команды. Для обычной страницы достаточно следующей строки. Для небольшого предпросмотра может понадобиться вернуться к первой или последней позиции, не меняя SQL-условие. Сначала ограничьте сам результат, затем выбирайте способ движения по нему.
- SQL ORDER BY: ID 1, 2, 3, 4, 5, 6
- OFFSET 1 / FETCH 3: результат ID 2, 3, 4
- Позиция курсора ABS 2: второй элемент, то есть ID 3
- COUNT с WHERE: 6, а не размер страницы 3
| Настройка или вызов | Смысл и место |
|---|---|
| PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY | Опция prepare: последовательное чтение; обычный выбор для одноразового экспорта |
| PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL | Опция prepare: запросить поддерживаемое драйвером позиционное чтение |
| Pdo\Odbc::ATTR_USE_CURSOR_LIBRARY | Опция открытия соединения, выбирает библиотеку/драйвер, а не форму PHP-массива |
| SQL_USE_DRIVER | Курсоры самого ODBC-driver; использованы в проектах |
| SQL_USE_IF_NEEDED / SQL_USE_ODBC | Подключить ODBC cursor library при необходимости / всегда; исторические варианты |
| fetch(FETCH_ASSOC, FETCH_ORI_FIRST/LAST) | Первая/последняя строка уже выбранного результата |
| FETCH_ORI_NEXT/PRIOR | Следующая/предыдущая строка |
| FETCH_ORI_ABS, n | Позиция n; в наблюдённом PDO_ODBC 1 — первая, 0 — до начала |
| FETCH_ORI_REL, delta | Сдвиг от текущей позиции: -1 на предыдущую, +1 на следующую |
Microsoft помечает Windows ODBC Cursor Library как устаревающую и рекомендует функциональность драйвера. Поэтому для нового проекта выбрано SQL_USE_DRIVER. PDO_ODBC запрашивает scrollability через ODBC; принятие режима зависит от установленного драйвера и конкретной команды. Если режим не поддержан, нужен понятный отказ или заранее выбранный forward-путь, а не молчаливая загрузка всего каталога в PHP.
$s = $db->prepare($boundedSql, [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]);
$s->execute($parameters);
$second = $s->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_ABS, 2);
$s->closeCursor();
// PDO_ODBC: вторая позиция выбранной страницы. Это не WHERE id=2.При выходе за границы результат fetch равен false; ошибка при включённых исключениях не должна маскироваться под пустую выборку. Для ABS 0 подготовка получила false и SQLSTATE 00000: это положение до первой строки. Следующий NEXT снова читает начало. В полном предпросмотре отдельно проверены FIRST/LAST, ABS/REL и фактические выбранные товары. После использования closeCursor освобождает результат; завершение бизнес-транзакции — другая операция.
rowCount(): int прежде всего описывает результат операции изменения данных. Для SELECT поведение зависит от драйвера и курсора. В подготовке PDO_ODBC forward-only вернул -1 и до, и после полного обхода; scrollable вернул размер выбранного набора. Ни один из этих вариантов не отвечает за общее количество категории при OFFSET/FETCH. Для счётчика используйте COUNT с тем же WHERE; для счёта уже ограниченной страницы достаточно count полученных строк.
Обсуждение заказов и rowCount=-1 показывает, как диагностическое число принимают за отсутствие совпадений. Здесь количество категории вычисляется SQL, а выбранная страница видна непосредственно. Подробные контракты: PDOStatement::rowCount, PDOStatement::fetch; переносимость сравните с результатами PDO_SQLSRV.
Показать три товара и перемещаться внутри выбранной страницы
Оператор просматривает следующую небольшую страницу каталога: ему нужны три товара, а счётчик должен сообщить, сколько товаров подходит вообще. Курсор позволяет ещё раз посмотреть первую или последнюю строку уже выбранной страницы. Он не заменяет ограничение запроса и не требует загрузить весь каталог в PHP.
- wiring: ID 1…6, отсортированные по цене и id
- SQL пропускает 1 строку и отдаёт 3: ID 2, 3, 4
- FIRST → 2; LAST → 4; ABS 2 → ID 3
- COUNT с тем же WHERE → 6; page.csv → только 3 строки
Минимальный вызов
$s = $db->prepare(
'SELECT id, title FROM items WHERE category = ? ORDER BY id OFFSET 1 ROWS FETCH NEXT 3 ROWS ONLY',
[PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL]
);
$s->execute(['wiring']);
$first = $s->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_FIRST);
$last = $s->fetch(PDO::FETCH_ASSOC, PDO::FETCH_ORI_LAST);
$s->closeCursor();
// В полном наборе выбранной страницы: FIRST id=2, LAST id=4.Опция CURSOR_SCROLL передаётся prepare для конкретной команды. FETCH_ASSOC выбирает форму строки; второй аргумент fetch выбирает направление. FIRST и LAST не принимают номер товара: они указывают позиции внутри результата SELECT. ORDER BY определяет эти позиции; OFFSET пропускает строки до формирования страницы. В полном проекте числовые OFFSET/FETCH привязаны как PARAM_INT, а соединение использует собственный курсор установленного драйвера.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Категория | wiring: всего 6 товаров |
| Страница | OFFSET 1, FETCH 3: ID 2, 3, 4 |
| Позиция 2 | Клемма | 200 |
| Позиция 3 | Лента | 300 |
| Позиция 4 | Хомут | 400 |
| FIRST / LAST | 2 / 4 |
| ABS 2 / REL -1 | 3 / 2 |
| ABS 0, затем NEXT | false, затем ID 2 |
| rowCount: forward / scroll | -1 → -1 / 3 |
| Файл | page.csv: три выбранных товара |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
function writeCsv178(string $filename,array $records): void {
$file=fopen(__DIR__.'/'.$filename,'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=p178OwnTable($db,$owned,'page','(id int PRIMARY KEY,title nvarchar(60) NOT NULL,category varchar(20) NOT NULL,price_cents int NOT NULL)');
$t=p178Qualified($name);
foreach ([[1,'Кабель','wiring',100],[2,'Клемма','wiring',200],[3,'Лента','wiring',300],
[4,'Хомут','wiring',400],[5,'Выключатель','wiring',500],[6,'Розетка','wiring',600],[7,'Кисть','paint',50]] as $record) {
p178Command($db,'INSERT INTO '.$t.' (id,title,category,price_cents) VALUES (?,?,?,?)',$record);
}
$sql='SELECT id,title,price_cents FROM '.$t.' WHERE category=? ORDER BY price_cents,id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY';
$s=null;$forward=[];
try {
$s=$db->prepare($sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_FWDONLY]);
$s->bindValue(1,'wiring',PDO::PARAM_STR);$s->bindValue(2,1,PDO::PARAM_INT);$s->bindValue(3,3,PDO::PARAM_INT);
$s->execute();$before=$s->rowCount();
while (($row=$s->fetch(PDO::FETCH_ASSOC))!==false) {$forward[]=$row;}
$after=$s->rowCount();
} finally {p178Release($s);}
$total=(int)p178Read($db,'SELECT COUNT(*) AS total FROM '.$t.' WHERE category=?',['wiring'])[0]['total'];
try {
// Соединение открыто с SQL_USE_DRIVER; библиотеку курсоров ODBC не включаем.
$s=$db->prepare($sql,[PDO::ATTR_CURSOR=>PDO::CURSOR_SCROLL]);
$s->bindValue(1,'wiring',PDO::PARAM_STR);$s->bindValue(2,1,PDO::PARAM_INT);$s->bindValue(3,3,PDO::PARAM_INT);
$s->execute();$scrollCount=$s->rowCount();
$first=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_FIRST);
$last=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_LAST);
$second=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_ABS,2);
$previous=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_REL,-1);
$beforeFirst=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_ABS,0);
$again=$s->fetch(PDO::FETCH_ASSOC,PDO::FETCH_ORI_NEXT);
} finally {p178Release($s);}
$records=[['id','title','price_cents']];
foreach ($forward as $row) {$records[]=[(string)$row['id'],$row['title'],(string)$row['price_cents']];}
writeCsv178('page.csv',$records);
$checks=[
'page_only'=>array_column($records,0)===['id','2','3','4'],
'total_separate'=>$total===6 && count($forward)===3,
'first_last'=>(string)($first['id']??'')==='2' && (string)($last['id']??'')==='4',
'absolute_relative'=>(string)($second['id']??'')==='3' && (string)($previous['id']??'')==='2',
'absolute_zero_boundary'=>$beforeFirst===false && (string)($again['id']??'')==='2',
'observed_row_counts'=>$before===-1 && $after===-1 && $scrollCount===3,
'file_written'=>is_file(__DIR__.'/page.csv') && filesize(__DIR__.'/page.csv')>0,
];
$rows=[['Категория','wiring: всего 6 товаров'],['Страница','OFFSET 1, FETCH 3: ID 2, 3, 4'],
['Позиция 2',$forward[0]['title'].' | '.$forward[0]['price_cents']],
['Позиция 3',$forward[1]['title'].' | '.$forward[1]['price_cents']],
['Позиция 4',$forward[2]['title'].' | '.$forward[2]['price_cents']],
['FIRST / LAST',(string)$first['id'].' / '.(string)$last['id']],
['ABS 2 / REL -1',(string)$second['id'].' / '.(string)$previous['id']],
['ABS 0, затем NEXT','false, затем ID '.(string)$again['id']],
['rowCount: forward / scroll',$before.' → '.$after.' / '.$scrollCount],['Файл','page.csv: три выбранных товара']];
$observations=['forward_rows'=>$forward,'row_count'=>['forward_before'=>$before,'forward_after'=>$after,'scroll_before'=>$scrollCount],
'count_query'=>$total,'positions'=>['first'=>$first,'last'=>$last,'absolute_2'=>$second,'relative_minus_1'=>$previous,'absolute_0'=>$beforeFirst,'next_after_0'=>$again]];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-page-preview','Показать три товара и перемещаться внутри выбранной страницы',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Категория — wiring: всего 6 товаров
Страница — OFFSET 1, FETCH 3: ID 2, 3, 4
Позиция 2 — Клемма | 200
Позиция 3 — Лента | 300
Позиция 4 — Хомут | 400
FIRST / LAST — 2 / 4
ABS 2 / REL -1 — 3 / 2
ABS 0, затем NEXT — false, затем ID 2
rowCount: forward / scroll — -1 → -1 / 3
Файл — page.csv: три выбранных товара
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать page.csv
Границы решения
ABS относится к номеру строки результата, а не к первичному ключу. Для проверяемого PDO_ODBC нумерация положительных ABS начинается с 1; 0 располагает курсор до первой строки. PDO_SQLSRV из предыдущей главы имеет другой наблюдённый договор. Значения rowCount сохраняются в JSON как диагностика, но не служат общим количеством подходящих товаров. SQL COUNT и страница читаются отдельными командами, поэтому при конкурентных изменениях возможно расхождение без согласованного снимка. Для очень глубоких страниц рассмотрите keyset-пагинацию по устойчивому ключу.
Попробуйте изменить условие
Теперь оператору нужен четвёртый товар категории по цене, но без загрузки первых трёх в PHP. Какая SQL-страница и какой FIRST понадобятся?
Разбор
Выберите OFFSET 3 ROWS FETCH NEXT 1 ROWS ONLY с тем же ORDER BY. Результат содержит только ID 4, поэтому FIRST указывает на него. Нельзя передать первичный ключ 4 в ABS и ждать тот же эффект: ABS 4 означает четвёртую строку уже полученного результата, которой в однострочной странице нет.
Байты вложения и их текстовое представление
Файл состоит из байтов, а hex — текст, которым эти байты можно записать. Например, один байт FF записывается двумя символами «FF». Если сохранить эти символы напрямую вместо декодирования, получится другой файл. Поэтому способность получить PHP string ещё не доказывает, что строка содержит исходные байты.
$input = fopen(__DIR__.'/source.bin', 'rb');
if ($input === false) { throw new RuntimeException('Нет исходного файла.'); }
$s = $db->prepare('INSERT INTO attachments (id, payload) VALUES (?, ?)');
$s->bindValue(1, 1, PDO::PARAM_INT);
$s->bindParam(2, $input, PDO::PARAM_LOB);
try { $s->execute(); }
finally { $s->closeCursor(); fclose($input); }Это первое применение LOB: передача входного файла. rb открывает поток для чтения байтов, bindParam связывает переменную с параметром и считывает её при execute. Поток должен существовать до окончания выполнения. Не задавайте для обычного IN ненулевую длину bindParam «на всякий случай»: в PDO_ODBC длина участвует в выборе OUTPUT. SQLNULL передавайте PARAM_NULL; режим PARAM_LOB имеет собственную ветку преобразования и не является универсальной меткой nullable binary.
Второе применение — восстановление сохранённого файла. В подготовке PHP 8.5.11 с Microsoft ODBC Driver 18 обычный fetch для VARBINARY возвратил uppercase hex-текст. bindColumn(PARAM_LOB) создал поток с тем же hex-текстом, а не автоматически восстановленные байты. Полный проект рядом сравнивает обычное представление и явный SQL CONVERT(...,2), затем валидирует чётный hex, декодирует и сверяет длину и SHA256 файла. Точные наблюдения закреплены за этой связкой; другой ODBC-driver нужно проверить отдельно.
| Состояние | Проверка в SQL | Как трактовать |
|---|---|---|
| Файл document | IS NULL = 0; DATALENGTH = 22 | Содержимое есть; сравнить байты |
| Пустой empty | IS NULL = 0; DATALENGTH = 0 | Файл прикреплён намеренно, но пуст |
| Отсутствующий missing | IS NULL = 1; DATALENGTH = NULL | Вложения нет |
| Hex FF | Два текстовых символа для одного байта | Декодировать по объявленному договору, не удалять NUL из файла |
PDO::PARAM_LOB описывает интерфейс больших данных, но не гарантирует один и тот же путь памяти у всех драйверов. В исходнике PDO_ODBC длинные получаемые колонки собираются в одну строку. Даже если общий PDO затем отдаёт memory stream, это не ленивое чтение из SQL Server. Ограничивайте размер вложения и выбирайте его только по нужному ID. Метаданные списка файлов — отдельный запрос без payload; так таблица документов не получает все двоичные тела.
Вопрос о переносе BLOB с обычного ODBC на PDO относится к старому Access-driver. Из него взята практическая проблема несовпадающего представления, но не опасный совет удалять байты NUL и не старые настройки odbc_binmode для PDOStatement. Решение этой главы разработано для наблюдённой современной связки и проверяет каждый байт.
См. полный проект вложения, PDOStatement::bindParam, PDOStatement::bindColumn и работу с файлами. Для формирования HTTP-ответа понадобятся отдельные права доступа и заголовки; наличие CLI-файла не выдаётся за проверку браузерной загрузки. Следующая глава объясняет, как завершить команду и всю операцию при ошибке.
Сохранить вложение из потока и получить исходные байты
Нужно передать приложение к заявке, а затем скачать тот же файл. Символ NUL и байт FF помогают обнаружить ошибочное текстовое преобразование. Проект пишет небольшой исходный файл, сохраняет его через входной поток и получает attachment.bin. Для отсутствующего и пустого вложения предусмотрены разные состояния.
Минимальный вызов
$s = $db->prepare('SELECT CONVERT(varchar(max), payload, 2) AS hex_text FROM attachments WHERE id = ?');
$s->execute([1]);
$hex = $s->fetchColumn();
if (is_string($hex)) {
$bytes = hex2bin($hex);
if ($bytes === false) { throw new RuntimeException('Некорректный hex.'); }
file_put_contents(__DIR__.'/attachment.bin', $bytes);
}
$s->closeCursor();
// HEX 4F444243... превращается обратно в байты файла, а не печатается как текст.CONVERT здесь — операция SQL Server: стиль 2 выдаёт две шестнадцатеричные цифры на каждый байт без префикса 0x. fetchColumn получает выбранное значение; hex2bin преобразует явное текстовое представление в байты. Это другой договор, чем случайное предположение о типе default fetch. Полный проект добавляет проверку размера, точное сравнение файла и раздельные NULL/EMPTY состояния; входной bindParam(PARAM_LOB) связывает PHP-поток с параметром INSERT.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Вложение | document: 22 байт |
| Точные байты | 4F44424331373800FF0D0A6174746163686D656E740A |
| Пустое вложение | empty: SQL NOT NULL, 0 байт |
| Отсутствующее вложение | missing: SQL NULL |
| Обычный fetch | VARBINARY получен как hex-текст |
| Файл | attachment.bin: байты совпали с source.bin |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p178Config();$db=p178Connect($config);$runtime=p178Runtime($db);
$name=p178OwnTable($db,$owned,'attachments','(id int PRIMARY KEY,label varchar(30) NOT NULL,payload varbinary(max) NULL)');
$t=p178Qualified($name);$payload="ODBC178\0\xFF\r\nattachment\n";
if (file_put_contents(__DIR__.'/source.bin',$payload)!==strlen($payload)) {throw new RuntimeException('Исходный файл записан не полностью.');}
$input=fopen(__DIR__.'/source.bin','rb');$s=null;
if ($input===false) {throw new RuntimeException('Исходный файл не открыт.');}
try {
$s=$db->prepare('INSERT INTO '.$t.' (id,label,payload) VALUES (1,?,?)');
$s->bindValue(1,'document',PDO::PARAM_STR);$s->bindParam(2,$input,PDO::PARAM_LOB);$s->execute();
} finally {p178Release($s);if (is_resource($input)) {fclose($input);}}
try {
$s=$db->prepare('INSERT INTO '.$t.' (id,label,payload) VALUES (2,?,?)');
$s->bindValue(1,'empty',PDO::PARAM_STR);$empty='';$s->bindParam(2,$empty,PDO::PARAM_LOB);$s->execute();
} finally {p178Release($s);}
try {
$s=$db->prepare('INSERT INTO '.$t.' (id,label,payload) VALUES (3,?,?)');
$s->bindValue(1,'missing',PDO::PARAM_STR);$s->bindValue(2,null,PDO::PARAM_NULL);$s->execute();
} finally {p178Release($s);}
$states=p178Read($db,'SELECT id,label,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS is_missing,DATALENGTH(payload) AS byte_length FROM '.$t.' ORDER BY id');
$found=p178Read($db,'SELECT payload,CONVERT(varchar(max),payload,2) AS payload_hex,DATALENGTH(payload) AS byte_length FROM '.$t.' WHERE id=?',[1])[0];
$hex=$found['payload_hex'];
if (!is_string($hex) || strlen($hex)>8192 || !preg_match('/\A(?:[0-9A-Fa-f]{2})*\z/D',$hex)) {throw new UnexpectedValueException('Нарушен договор hex для небольшого вложения.');}
$bytes=hex2bin($hex);
if ($bytes===false) {throw new UnexpectedValueException('Hex не преобразован в байты.');}
if (file_put_contents(__DIR__.'/attachment.bin',$bytes)!==strlen($bytes)) {throw new RuntimeException('Вложение записано не полностью.');}
$defaultHex=is_string($found['payload']) && strtoupper($found['payload'])===strtoupper($hex);
$checks=[
'file_exact'=>$bytes===$payload && hash_file('sha256',__DIR__.'/attachment.bin')===hash('sha256',$payload),
'server_length'=>(int)$found['byte_length']===strlen($payload),
'null_distinct'=>(int)$states[2]['is_missing']===1 && $states[2]['byte_length']===null,
'empty_distinct'=>(int)$states[1]['is_missing']===0 && (int)$states[1]['byte_length']===0,
'default_hex_observed'=>$defaultHex,
'binary_markers'=>str_contains($bytes,"\0") && str_contains($bytes,"\xFF"),
];
$rows=[['Вложение','document: '.strlen($bytes).' байт'],['Точные байты',strtoupper(bin2hex($bytes))],
['Пустое вложение','empty: SQL NOT NULL, 0 байт'],['Отсутствующее вложение','missing: SQL NULL'],
['Обычный fetch','VARBINARY получен как hex-текст'],['Файл','attachment.bin: байты совпали с source.bin']];
$observations=['states'=>$states,'default_representation'=>'hex_text','default_php_type'=>get_debug_type($found['payload']),
'bytes'=>strlen($bytes),'sha256'=>hash('sha256',$bytes),'hex'=>strtoupper(bin2hex($bytes)),
'sql_hex_matches_default'=>$defaultHex];
} finally {
if ($db instanceof PDO) {try {$removed=p178Cleanup($db,$owned);} finally {p178Close($db);}}
}
p178Report('pdo178-attachment-transfer','Сохранить вложение из потока и получить исходные байты',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Вложение — document: 22 байт
Точные байты — 4F44424331373800FF0D0A6174746163686D656E740A
Пустое вложение — empty: SQL NOT NULL, 0 байт
Отсутствующее вложение — missing: SQL NULL
Обычный fetch — VARBINARY получен как hex-текст
Файл — attachment.bin: байты совпали с source.bin
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать восстановленное вложение attachment.bin
Границы решения
Это небольшой файл, а не доказательство загрузки гигабайт с постоянной памятью. Hex удваивает объём текста, и PDO_ODBC собирает длинную получаемую колонку в памяти. Поток на входе и поток, который PDO может создать из уже полученной строки, имеют разную стоимость. NULL привязан как PARAM_NULL: перенос PARAM_LOB-null из PDO_SQLSRV здесь неверен. Нельзя удалить все NUL из данных для «починки» файла — это уничтожит законные байты. Для HTTP понадобятся проверка доступа, фиксированный MIME/имя и отдельный реально проверяемый ответ; этот CLI-проект создаёт файл.
Попробуйте изменить условие
Входной файл теперь пустой, но загружен пользователем намеренно. Как отличить его от записи, к которой файл вообще не прикрепляли?
Разбор
Сохраняйте пустое содержимое как непустой факт вложения с нулевым DATALENGTH; отсутствие — SQL NULL. Проверяйте IS NULL, а не логическую истинность PHP-строки. Не заменяйте пустую строку на NULL с помощью empty(). Временный входной поток закройте после выполнения и освобождения statement.
Источники и обсуждения
- PHP Manual: PDO_ODBC
- PHP Manual: Pdo\Odbc
- PHP Manual: формы и положение fetch
- PHP Manual: rowCount
- PHP Manual: метаданные колонки
- PHP Manual: большие данные PDO
- PDO_ODBC PHP8.5.11: statement
- PDO_ODBC PHP8.5.11: соединение
- Microsoft: SQLFetchScroll
- Microsoft: ODBC Cursor Library
- Microsoft: CAST/CONVERT и binary styles
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


