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

Дерево руководства
Глава 293 · После подключения и подготовленного запроса
PDO_FIREBIRD: даты выгрузки, точные суммы, вложения и текущая строка
Расписание, точные суммы, двоичное вложение и сверка текущих строк: как сохранить смысл результата PDO_FIREBIRD.
Сохранить смысл результата при передаче в PHP
Данные уже выбраны из базы. Теперь их надо правильно показать человеку или передать другой программе: дата не должна превратиться в неподписанную часовую зону, длинный номер — потерять цифру, а двоичный файл — испортиться от текстовой обработки. Иногда чтение ещё связано с изменением той строки, на которой остановился запрос. В этой главе четыре небольших сервисных результата объясняют эти разные обязанности.
| Нужен результат | Какие данные проверяем | Полезный файл |
|---|---|---|
| Расписание выбранного дня | Два визита, дата 08.11.2026, время 09:05; в SQL остаются секунды | dates.csv |
| Обмен точными значениями | Номер 9007199254740993, цена 0.10 и итог 40.28 | values.csv |
| Вложение конкретной заявки | Девять байт MTR2, включая 00 и FF; отдельно пустое и отсутствующее | attachment.bin |
| Сверка уже читаемых строк | Кабель 3 → 5, хомут 0 → 1; третья позиция остаётся 9 | positions.csv |
PDO соединяет PHP-приложение с базой: объект соединения создаёт подготовленные запросы, а запрос возвращает строки или сохраняет изменение. PDO_FIREBIRD реализует этот интерфейс для Firebird; SQL, типы и транзакции сохраняют особенности базы.
Примеры рассчитаны на отдельную учебную базу. Проверяемая связка: PHP 8.5.11 с PDO_FIREBIRD и Firebird 5.0.4. Pdo\Firebird появился в PHP 8.4; старый new PDO с префиксом firebird: остаётся доступным. Введение в PDO объясняет соединение и подготовленный запрос.
Нужны основы подключения Firebird и подготовленного запроса. Объект $db представляет соединение, PDOStatement — отдельный запрос; его fetch возвращает PHP-значение. Знак -> вызывает метод, :: выбирает именованную константу класса. При работе с несколькими связанными изменениями явная транзакция задаёт одну границу commit или rollback. В каждом коротком фрагменте рассматривается один механизм; полные проекты создают свои исходные данные и показывают проверяемый результат.
- SQL DECIMAL(18,2): 0.10 → PDO → PHP string "0.10" → текст CSV
- SQL BIGINT: 9007199254740993 → 64-bit PHP int → строка номера для обмена
- SQL DATE: 2026-11-08 → формат соединения → строка 08.11.2026
- SQL BLOB: байты → буфер PDO → двоичный файл; не HTML и не обычный JSON-текст
Эта схема показывает выбранный драйвер и версии, а не единый закон всех PDO. Например, в предыдущем PDO_CUBRID BIGINT приходил иначе. Копировать прежние проверки типов без проверки нового драйвера нельзя. В SQL-примерах имя колонки AS "visit_date" заключено в двойные кавычки, чтобы PHP получил ровно этот регистр; обычные некавыченные имена Firebird приводит к верхнему регистру.
Форматы DATE, TIME и TIMESTAMP: строка не меняет дату
Формат вывода нужен, когда один и тот же день должен выглядеть как 08.11.2026 в расписании и как 2026-11-08 в обмене. SQL-тип DATE остаётся датой; меняется строка, которую получает PHP. Не сохраняйте красивую строку обратно в VARCHAR вместо исходной даты только ради печати.
| Настройка соединения Pdo\Firebird | Какой SQL-результат форматирует | Исходное представление | Один вариант |
|---|---|---|---|
| ATTR_DATE_FORMAT | DATE | %Y-%m-%d → 2026-11-08 | %d.%m.%Y → 08.11.2026 |
| ATTR_TIME_FORMAT | TIME | %H:%M:%S → 09:05:06 | %H:%M → 09:05 |
| ATTR_TIMESTAMP_FORMAT | TIMESTAMP | %Y-%m-%d %H:%M:%S → 2026-11-07 16:45:12 | %Y-%m-%dT%H:%M:%S → 2026-11-07T16:45:12 |
Эти имена принадлежат появившемуся в PHP 8.4 классу Pdo\Firebird. Исторические PDO::FB_ATTR_DATE_FORMAT, PDO::FB_ATTR_TIME_FORMAT и PDO::FB_ATTR_TIMESTAMP_FORMAT относятся к тем же настройкам драйвера. Здесь используется современное имя; наличие класса и загруженного драйвера проверяется в лабораторном окружении. Общий справочник PDO объясняет сам setAttribute/getAttribute.
$old = $db->getAttribute(Pdo\Firebird::ATTR_DATE_FORMAT);
try {
if (!$db->setAttribute(Pdo\Firebird::ATTR_DATE_FORMAT, '%Y-%m-%d')) {
throw new RuntimeException('Формат даты не установлен');
}
// Нужный SELECT выполняется и читается здесь.
} finally {
$db->setAttribute(Pdo\Firebird::ATTR_DATE_FORMAT, $old);
}getAttribute читает действующий шаблон строкой; setAttribute получает идентификатор настройки и новый шаблон, возвращает bool либо сообщает ошибку согласно режиму PDO. Сначала сохраняется прежнее значение, затем в try выполняется нужное чтение, finally возвращает настройку даже при исключении. Это второй сценарий применения: общий экспортёр не должен менять формат последующего экрана на том же соединении.
| Элемент шаблона | Смысл | Не путать |
|---|---|---|
| %Y, %m, %d | Год, месяц, день числом | Y-m-d у DateTime::format — другой синтаксис |
| %H, %M, %S | Часы в 24-часовом виде, минуты, секунды | %m — месяц, %M — минуты |
| Точки, дефисы, пробел, T | Печатаются как разделители | T не задаёт UTC и не преобразует часовую зону |
| Названия месяца/дня и редкие спецификаторы | Зависят от strftime и окружения | Для устойчивого обмена используйте короткую числовую запись |
В php-8.5.11 форматирование обычных DATE/TIME/TIMESTAMP проходит через C strftime с буфером 80 байт. Установка строки сама по себе не проверяет смысл каждого спецификатора. Поэтому не берите произвольный пользовательский шаблон и не обещайте одинаковую работу расширений strftime во всех ОС. Выберите известные короткие варианты и проверяйте конечную строку. SQL NULL остаётся null. Формат применяется при получении значения; это не команда UPDATE и не изменение индекса базы.
Пример использует dialect 3, где DATE содержит только дату. В dialect 1 у DATE иной исторический смысл. Firebird также умеет TIME/TIMESTAMP WITH TIME ZONE, но наличие этих типов не означает, что обычный TIMESTAMP уже хранит зону. Здесь таких столбцов нет. Если контракт API требует точный момент, определите исходную зону и преобразование отдельно; не приписывайте Z к локальному времени.
Вопрос о том, какой слой задаёт формат даты PDO полезен как постановка задачи. Ответ с настройками Yii не заменяет контракт PDO_FIREBIRD, а общее утверждение «PDO никогда не форматирует даты» не подходит этой реализации. Расписание дня показывает прямой эффект конкретных атрибутов.
Выгрузить расписание дня с понятными датой и временем
Диспетчеру нужен файл визитов на 8 ноября. На листе удобно читать 08.11.2026 и 09:05, но в базе должно остаться время 09:05:06. Программа выбирает только нужный день, меняет представление трёх SQL-типов на время выгрузки, сохраняет dates.csv и возвращает исходные настройки. Следующий день не загружается ради последующего отбрасывания в PHP.
- SQL: DATE 2026-11-08, TIME 09:05:06, TIMESTAMP 2026-11-07 16:45:12
- WHERE выбирает два визита; ATTR_*_FORMAT меняет только строку результата
- CSV: 08.11.2026; 09:05; 2026-11-07T16:45:12
- Вернули форматы → тот же SQL снова показывает 09:05:06
Минимальный вызов
$db->setAttribute(Pdo\Firebird::ATTR_DATE_FORMAT, '%d.%m.%Y');
$statement = $db->query("SELECT DATE '2026-11-08' AS \"day\" FROM RDB\$DATABASE");
echo $statement->fetchColumn(); // 08.11.2026
$statement->closeCursor();setAttribute задаёт настройку конкретного соединения; Pdo\Firebird::ATTR_DATE_FORMAT выбирает именно DATE. В строке %d — день, %m — месяц, %Y — год; точки остаются точками. Это спецификаторы strftime, а не шаблон d.m.Y для DateTime::format. RDB$DATABASE позволяет получить одну демонстрационную строку. AS "day" задаёт имя колонки; fetchColumn читает её значение. Полный проект отдельно задаёт TIME и TIMESTAMP и восстанавливает прежние настройки в finally.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Визитов выбранного дня | 2 |
| Первый визит | Замена кабеля — 08.11.2026, 09:05 |
| Регистрация первого визита | 2026-11-07T16:45:12 |
| Регистрация второго визита | SQL NULL |
| Секунды в базе после выгрузки | 09:05:06 |
| Файл | dates.csv |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p176Config();$db=p176Connect();$runtime=p176Runtime($db);
function writeCsv176(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=p176OwnName($db,$owned,'TABLE','dates');$t=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$t.' (ID INT PRIMARY KEY,TASK VARCHAR(80),VISIT_DATE DATE,VISIT_TIME TIME,REGISTERED_AT TIMESTAMP)');
foreach([
[1,'Замена кабеля','2026-11-08','09:05:06','2026-11-07 16:45:12'],
[2,'Проверка щита','2026-11-08','14:30:00',null],
[3,'Визит завтра','2026-11-09','10:00:00','2026-11-07 17:00:00'],
] as $record) {p176Command($db,'INSERT INTO '.$t.' (ID,TASK,VISIT_DATE,VISIT_TIME,REGISTERED_AT) VALUES (?,?,?,?,?)',$record);}
$sql='SELECT ID AS "id",TASK AS "task",VISIT_DATE AS "visit_date",VISIT_TIME AS "visit_time",REGISTERED_AT AS "registered_at" FROM '.$t.' WHERE VISIT_DATE=? ORDER BY VISIT_TIME,ID';
$attributes=[Pdo\Firebird::ATTR_DATE_FORMAT,Pdo\Firebird::ATTR_TIME_FORMAT,Pdo\Firebird::ATTR_TIMESTAMP_FORMAT];
$saved=[];foreach($attributes as $attribute) {$saved[$attribute]=$db->getAttribute($attribute);}
$before=p176Read($db,$sql,['2026-11-08']);$formatted=[];
try {
$wanted=['%d.%m.%Y','%H:%M','%Y-%m-%dT%H:%M:%S'];
foreach($attributes as $i=>$attribute) {
if(!$db->setAttribute($attribute,$wanted[$i]) || $db->getAttribute($attribute)!==$wanted[$i]) {throw new RuntimeException('Формат не установлен.');}
}
$formatted=p176Read($db,$sql,['2026-11-08']);
} finally {foreach($saved as $attribute=>$format) {if(!$db->setAttribute($attribute,$format)) {throw new RuntimeException('Исходный формат не восстановлен.');}}}
$restored=p176Read($db,$sql,['2026-11-08']);
$csv=[['id','task','visit_date','visit_time','registered_at']];
foreach($formatted as $r) {$csv[]=[$r['id'],$r['task'],$r['visit_date'],$r['visit_time'],$r['registered_at']??'[NULL]'];}
writeCsv176('dates.csv',$csv);
$rows=[['Визитов выбранного дня',count($formatted)],['Первый визит','Замена кабеля — 08.11.2026, 09:05'],
['Регистрация первого визита',$formatted[0]['registered_at']],['Регистрация второго визита','SQL NULL'],
['Секунды в базе после выгрузки',$restored[0]['visit_time']],['Файл','dates.csv']];
$checks=['only_requested_date'=>array_column($formatted,'id')===[1,2],
'date_format'=>$formatted[0]['visit_date']==='08.11.2026','time_format'=>$formatted[0]['visit_time']==='09:05',
'timestamp_format'=>$formatted[0]['registered_at']==='2026-11-07T16:45:12',
'null_preserved'=>$formatted[1]['registered_at']===null,'default_seconds'=>$before[0]['visit_time']==='09:05:06',
'values_not_changed'=>$restored===$before,'formats_restored'=>array_map(fn($a)=>$db->getAttribute($a),$attributes)===array_values($saved),
'artifact_written'=>is_file(__DIR__.'/dates.csv')];
$observations=['default_rows'=>$before,'export_rows'=>$formatted,'restored_rows'=>$restored,
'format_strings'=>array_values($saved),'timestamps_have_timezone'=>false];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-date-export','Выгрузить расписание дня с понятными датой и временем',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Визитов выбранного дня — 2
Первый визит — Замена кабеля — 08.11.2026, 09:05
Регистрация первого визита — 2026-11-07T16:45:12
Регистрация второго визита — SQL NULL
Секунды в базе после выгрузки — 09:05:06
Файл — dates.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать расписание CSV
Границы решения
У этих TIME/TIMESTAMP нет часовой зоны. Буква T между датой и временем не делает значение UTC; Z или смещение нельзя добавлять без договора о зоне. Укороченный вывод времени скрывает секунды, а не удаляет их. Стандартный формат PDO не переносит дробную часть секунд; если она нужна внешней системе, предусмотрите отдельное точное SQL-представление и его проверку. Настройка действует на соединение, поэтому библиотечная функция не должна оставлять чужой формат изменённым.
Попробуйте изменить условие
Покажите в CSV секунды визита, сохранив краткую дату. Нужно ли менять тип столбца или UPDATE?
Разбор
Нет. Для ATTR_TIME_FORMAT задайте %H:%M:%S. Первая строка станет 09:05:06; DATE и TIMESTAMP не изменятся. Нужен новый ожидаемый CSV, но данные SQL останутся прежними.
Точные суммы и длинные идентификаторы
У номера заказа и цены разные правила. Номер — идентификатор, по которому нужно найти ровно ту же запись; арифметика над ним обычно не нужна. Цена участвует в точном расчёте, но должна сохранять масштаб. Объединять оба случая одним преобразованием (float) нельзя.
| SQL в проекте | PHP в проверяемой 64-bit связке | Что делаем дальше |
|---|---|---|
| BIGINT 9007199254740993 | int, если он помещается в 64-bit zend_long | Перед внешним обменом явно (string), без промежуточного float |
| DECIMAL(18,2) 0.10 | string "0.10" | Оставляем десятичную запись, расчёт выполняем в SQL |
| INT 0 | int 0 | Нулевое значение допустимо; проверяем строго |
| VARCHAR NULL, пустая строка, "0" | null, string "", string "0" | Выбираем разные подписи и правила в приложении |
$statement = $db->prepare(
'SELECT CAST(UNIT_PRICE * QTY AS DECIMAL(18,2)) AS "line_total"
FROM ORDER_LINES WHERE ID=?'
);
$statement->bindValue(1, 1, PDO::PARAM_INT);
$statement->execute();
$amount = $statement->fetchColumn();
if ($amount === false) { throw new RuntimeException('Позиция не найдена'); }
echo $amount; // Например, 0.30 для цены 0.10 и количества 3.
$statement->closeCursor();Умножение выполняет база над типизированными значениями; CAST задаёт требуемый тип результата. Это не округление через PHP float. Проверка === false отличает отсутствие строки; значение "0.00" является успешной нулевой суммой. Во втором применении полный проект считает общий итог SQL SUM и отдельно сохраняет длинные номера для обмена.
Точность DECIMAL и ограничения бизнеса — разные вещи. Firebird может хранить диапазон шире объявленной precision за счёт физического типа; запрет отрицательной цены, предел количества и доступ пользователя задаются отдельно. Масштаб 2 означает два десятичных разряда, но нельзя произвольно переносить этот масштаб на валюту или измерение с другим правилом. Внешние большие числа передавайте десятичной строкой; для PHP-вычислений за пределами int выбирают подходящую библиотеку точной арифметики, а не молчаливый float.
На 32-битном PHP представление BIGINT может быть строковым; эта глава проверяет PHP x64. В Firebird 5 высокоточные выражения могут давать INT128 или DECFLOAT. Наш CAST к DECIMAL(18,2) явно ограничивает проверяемый результат; он не доказывает поддержку всех расширенных числовых типов каждым старым клиентом. При переносе изменяйте ожидаемые типы по фактам, сохраняя смысл и точность данных.
Сохранить длинный номер и посчитать сумму без float
Обмен с другой системой содержит длинный номер позиции и денежную цену. Если пропустить номер через float, можно потерять последнюю цифру; если считать деньги обычной двоичной дробью PHP, возникает другая точность. Проект сохраняет входные десятичные строки, считает 0.10 × 3 и 19.99 × 2 в SQL и отдаёт CSV с точной суммой 40.28.
- Ввод: business_id="9007199254740993", price="0.10", qty=3
- Bind строки → BIGINT и DECIMAL в SQL → CAST(price*qty AS DECIMAL(18,2))
- Строки итогов: 0.30 + 39.98 + 0.00 → SUM = 40.28
- NULL / пустая строка / текст "0" → три разные метки CSV
Минимальный вызов
$statement = $db->prepare('SELECT CAST(? AS DECIMAL(18,2)) AS price FROM RDB$DATABASE');
$statement->bindValue(1, '0.10', PDO::PARAM_STR);
$statement->execute();
$price = $statement->fetchColumn();
echo $price; // 0.10
$statement->closeCursor();Кавычки вокруг 0.10 означают строку с точной десятичной записью, а не PHP float. PARAM_STR передаёт эту строку; CAST просит базу превратить её в DECIMAL с двумя десятичными знаками. В DECIMAL(18,2) 18 — общая точность, 2 — масштаб после точки. Полученный PHP-тип зависит от драйвера: в проверяемом PDO_FIREBIRD масштабное число возвращается строкой, BIGINT на 64-битном PHP — int. Для внешнего идентификатора всё равно явно выбираем строковый формат.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Номер первой позиции | 9007199254740993 |
| Кабель: 0.10 × 3 | 0.30 |
| Хомут: 19.99 × 2 | 39.98 |
| Всего по SQL | 40.28 |
| Три состояния примечания | NULL; EMPTY; TEXT:0 |
| Файл | values.csv |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p176Config();$db=p176Connect();$runtime=p176Runtime($db);
function writeCsv176(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=p176OwnName($db,$owned,'TABLE','exact');$t=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$t.' (BUSINESS_ID BIGINT PRIMARY KEY,TITLE VARCHAR(80),UNIT_PRICE DECIMAL(18,2),QTY INT,NOTE VARCHAR(40))');
foreach([
['9007199254740993',"Кабель O'Reilly",'0.10',3,null],
['9007199254740994','Хомут','19.99',2,''],
['9007199254740995','Образец','0.00',0,'0'],
] as $v) {p176Command($db,'INSERT INTO '.$t.' (BUSINESS_ID,TITLE,UNIT_PRICE,QTY,NOTE) VALUES (?,?,?,?,?)',$v);}
$items=p176Read($db,'SELECT BUSINESS_ID AS "business_id",TITLE AS "title",UNIT_PRICE AS "unit_price",QTY AS "qty",CAST(UNIT_PRICE*QTY AS DECIMAL(18,2)) AS "line_total",NOTE AS "note" FROM '.$t.' ORDER BY BUSINESS_ID');
$sum=p176Read($db,'SELECT CAST(SUM(UNIT_PRICE*QTY) AS DECIMAL(18,2)) AS "total" FROM '.$t);
$csv=[['business_id','title','unit_price','qty','line_total','note_state']];$states=[];
foreach($items as $item) {
$state=$item['note']===null?'NULL':($item['note']===''?'EMPTY':'TEXT:'.$item['note']);$states[]=$state;
$csv[]=[(string)$item['business_id'],$item['title'],$item['unit_price'],$item['qty'],$item['line_total'],$state];
}
writeCsv176('values.csv',$csv);
$rows=[['Номер первой позиции',(string)$items[0]['business_id']],['Кабель: 0.10 × 3',$items[0]['line_total']],
['Хомут: 19.99 × 2',$items[1]['line_total']],['Всего по SQL',$sum[0]['total']],
['Три состояния примечания',implode('; ',$states)],['Файл','values.csv']];
$checks=['large_id_exact'=>(string)$items[0]['business_id']==='9007199254740993',
'bigint_native_int'=>is_int($items[0]['business_id'])&&PHP_INT_SIZE===8,
'decimal_native_string'=>is_string($items[0]['unit_price'])&&$items[0]['unit_price']==='0.10',
'line_totals'=>array_column($items,'line_total')===['0.30','39.98','0.00'],
'exact_sum'=>$sum[0]['total']==='40.28','null_empty_zero'=>$states===['NULL','EMPTY','TEXT:0'],
'zero_quantity'=>$items[2]['qty']===0,'unicode_apostrophe'=>$items[0]['title']==="Кабель O'Reilly",'artifact_written'=>is_file(__DIR__.'/values.csv')];
$observations=['items'=>$items,'total'=>$sum[0]['total'],'types'=>['business_id'=>get_debug_type($items[0]['business_id']),'price'=>get_debug_type($items[0]['unit_price']),'qty'=>get_debug_type($items[0]['qty'])],
'external_identifier_format'=>'decimal string; never float'];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-exact-values','Сохранить длинный номер и посчитать сумму без float',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Номер первой позиции — 9007199254740993
Кабель: 0.10 × 3 — 0.30
Хомут: 19.99 × 2 — 39.98
Всего по SQL — 40.28
Три состояния примечания — NULL; EMPTY; TEXT:0
Файл — values.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать точные значения CSV
Границы решения
CSV не задаёт тип ячейки. При открытии в электронной таблице импортируйте колонку номера как текст, иначе приложение может округлить её. Для JSON-контракта также передавайте идентификатор строкой, если клиент не умеет точно представлять все 64-битные числа. Precision DECIMAL в Firebird не заменяет CHECK бизнес-диапазона. SQL NULL означает отсутствие значения, пустой текст — известную пустую строку, "0" — непустой текст; empty() смешало бы эти случаи. В этой главе проверяется dialect 3 и DECIMAL(18,2), а не все варианты INT128/DECFLOAT.
Попробуйте изменить условие
Добавьте отрицательную корректировку цены -0.01 с qty=1. Каким способом получить новый итог?
Разбор
Передайте строку -0.01 параметром, сохраните DECIMAL и оставьте SQL SUM с явным DECIMAL-результатом. Итог станет 40.27. Не делайте промежуточный (float) и не склеивайте ввод в SQL.
PARAM_LOB: содержимое, память и освобождение
Вложение — содержимое конкретной заявки, а не вся строка каталога. Пока пользователь смотрит список, достаточно ID, имени и заранее сохранённых метаданных; байты выбирают по запросу отдельного вложения. BLOB SUB_TYPE BINARY не задаёт MIME-тип и не говорит, что содержимое можно безопасно вставить в HTML.
- fopen даёт PHP resource → bindParam связывает переменную с параметром
- execute читает весь поток в память → связанная переменная становится string
- Firebird сохраняет BLOB → SELECT нужного ID собирает двоичную PHP string
- file_put_contents сохраняет байты; в HTML выводятся HEX/размер, а не исходный бинарный текст
Общая документация PDO описывает PARAM_LOB через Streams API. Важно проверить реализацию: текущий PDO_FIREBIRD копирует весь входной поток, а обычное чтение BLOB возвращает строку полного содержимого. Это отличается от PDO_CUBRID из предыдущей главы. Поэтому выбор PARAM_LOB сам по себе не обеспечивает потоковую передачу с постоянным объёмом памяти.
Если привязать выходную колонку через bindColumn с PDO::PARAM_LOB и прочитать FETCH_BOUND, общий слой PDO может представить уже полученное содержимое как поток памяти. В подготовке этой версии такой resource читался и после closeCursor/commit. Это иной объект, чем серверный ленивый locator: источник уже загружен. Закройте созданный PHP-поток, когда он больше не нужен, и не переносите правила его времени жизни на другой драйвер.
| Вызов/состояние | Правило владения и результата |
|---|---|
| fopen(path, "rb") | Путь назначен приложением; проверить false. Файл должен оставаться доступен до execute. |
| bindParam(1, $input, PDO::PARAM_LOB) | Первый параметр нумеруется с 1; переменная связывается по ссылке и может измениться после execute. |
| execute и finally | Ошибку обработать по ERRMODE; закрывать input только если он всё ещё resource, затем освободить statement. |
| fetch выбранного BLOB | Строка с произвольными байтами либо null. Нулевой байт не является концом строки PHP. |
| Пустой BLOB и SQL NULL | Обычный fetch этой версии даёт null в обоих случаях. Выбирайте SQL IS NULL и OCTET_LENGTH вместе с данными: 0 байт и отсутствие — разные состояния. |
| Двоичные данные в HTML/JSON | Использовать HEX/Base64 или метаданные; не отдавать произвольный не-UTF8 текст как JSON-строку. |
$statement = $db->prepare(
'SELECT PAYLOAD, CASE WHEN PAYLOAD IS NULL THEN 1 ELSE 0 END AS "is_missing"
FROM ATTACHMENTS WHERE ID=?'
);
$statement->execute([$attachmentId]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
if ($row === false) { throw new RuntimeException('Запись не найдена'); }
if ($row['is_missing'] === 1) { throw new RuntimeException('Вложение ещё не предоставлено'); }
$bytes = $row['PAYLOAD'] ?? ''; // SQL уже отличил пустой BLOB от NULL.
// Здесь заранее проверены право на запись и допустимый размер вложения.
file_put_contents($controlledOutputPath, $bytes);
$statement->closeCursor();Это второе полезное применение — получение уже сохранённого вложения. Запись, отсутствие вложения и пустой файл различаются. Проверять допустимый размер только strlen после fetch поздно для защиты от загрузки огромного BLOB: для реального сервиса ограничение должно действовать при приёме, а метаданные помогают отказать до выбора содержимого. Полный проект с девятью байтами контролирует свой источник и сравнивает файл целиком.
Обсуждение исчерпания памяти при чтении BLOB из Firebird показывает практический риск массовой выгрузки. Увеличение memory_limit или вызов сборщика мусора не исправляют выбор ненужных больших объектов. В этой главе нет обещания HTTP-доставки или теста гигабайтного файла; проверен конкретный небольшой двоичный результат.
Прочитать одно вложение с теми же двоичными байтами
К сервисной заявке приложен маленький файл прибора MTR2. В нём есть нулевые байты и FF, поэтому его нельзя перекодировать как текст или непосредственно включать в JSON. Проект сохраняет файл через PARAM_LOB, выбирает ровно это вложение и создаёт attachment.bin. HEX в HTML позволяет видеть конкретное содержимое, SHA-256 дополнительно сравнивает байты.
- source.hex → 9 байт MTR2, 00, FF и другие значения
- Открытый input → PARAM_LOB → execute → переменная стала строкой
- WHERE ID=1 → одна двоичная строка → attachment.bin
- ID 2: SQL length 0, IS NULL=false; ID 3: SQL NULL → разные состояния заявки
Минимальный вызов
$input = fopen('input.bin', 'rb');
$statement = $db->prepare('INSERT INTO attachments (payload) VALUES (?)');
$statement->bindParam(1, $input, PDO::PARAM_LOB);
$statement->execute();
if (is_resource($input)) { fclose($input); }
$statement->closeCursor();rb открывает файл для двоичного чтения. bindParam связывает именно переменную $input: драйвер читает её при execute. В этом PDO_FIREBIRD он полностью читает поток и заменяет связанную переменную строкой; безусловный fclose($input) после execute поэтому неверен. is_resource проверяет, осталось ли что закрывать. BLOB SUB_TYPE BINARY хранит байты; обычный fetch этой версии возвращает строку со всем содержимым. Полный проект показывает finally и точную проверку результата.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Вложение | Измерение MTR2 |
| Байт в файле | 9 |
| HEX файла | 4d5452320000ff10fe |
| Тип обычного fetch | string |
| Переменная после PARAM_LOB | string |
| Пустое и отсутствующее | SQL: 0 байт / не NULL; NULL / отсутствует |
| Файл | attachment.bin |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p176Config();$db=p176Connect();$runtime=p176Runtime($db);
$name=p176OwnName($db,$owned,'TABLE','blob');$t=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$t.' (ID INT PRIMARY KEY,TITLE VARCHAR(80),PAYLOAD BLOB SUB_TYPE BINARY)');
$bytes=hex2bin(trim(file_get_contents(__DIR__.'/source.hex')));
if($bytes===false||file_put_contents(__DIR__.'/input.bin',$bytes)!==strlen($bytes)) {throw new RuntimeException('Учебное вложение не подготовлено.');}
$input=fopen(__DIR__.'/input.bin','rb');
if($input===false) {throw new RuntimeException('Источник не открыт.');}
$s=$db->prepare('INSERT INTO '.$t.' (ID,TITLE,PAYLOAD) VALUES (1,?,?)');
try {
$s->bindValue(1,'Измерение MTR2',PDO::PARAM_STR);
$s->bindParam(2,$input,PDO::PARAM_LOB);
$s->execute();$inputTypeAfter=get_debug_type($input);
} finally {if(is_resource($input)) {fclose($input);}p176Release($s);}
p176Command($db,'INSERT INTO '.$t.' (ID,TITLE,PAYLOAD) VALUES (2,?,?)',['Пустое вложение','']);
p176Command($db,'INSERT INTO '.$t.' (ID,TITLE,PAYLOAD) VALUES (3,?,?)',['Вложение отсутствует',null]);
// Выбирается одно содержимое; это не каталог всех файлов.
$selected=p176Read($db,'SELECT TITLE AS "title",PAYLOAD AS "payload" FROM '.$t.' WHERE ID=?',[1]);
$content=$selected[0]['payload'];
if(!is_string($content)||strlen($content)>1024) {throw new RuntimeException('Неверное или слишком большое учебное вложение.');}
if(file_put_contents(__DIR__.'/attachment.bin',$content)!==strlen($content)) {throw new RuntimeException('Вложение записано не полностью.');}
$states=p176Read($db,'SELECT ID AS "id",PAYLOAD AS "payload",CASE WHEN PAYLOAD IS NULL THEN 1 ELSE 0 END AS "is_missing",OCTET_LENGTH(PAYLOAD) AS "byte_length" FROM '.$t.' WHERE ID IN (2,3) ORDER BY ID');
$rows=[['Вложение',$selected[0]['title']],['Байт в файле',strlen($content)],['HEX файла',bin2hex($content)],
['Тип обычного fetch',get_debug_type($content)],['Переменная после PARAM_LOB',$inputTypeAfter],['Пустое и отсутствующее','SQL: 0 байт / не NULL; NULL / отсутствует'],['Файл','attachment.bin']];
$checks=['exact_binary'=>$content===$bytes,'nul_and_ff'=>str_contains($content,"\0")&&str_contains($content,"\xff"),
'default_fetch_string'=>is_string($content),'bound_input_converted'=>$inputTypeAfter==='string',
'empty_distinct'=>$states[0]['is_missing']===0&&$states[0]['byte_length']===0&&$states[1]['is_missing']===1&&$states[1]['byte_length']===null,
'fetch_null_ambiguity'=>$states[0]['payload']===null&&$states[1]['payload']===null,
'file_equal'=>file_get_contents(__DIR__.'/attachment.bin')===$bytes,
'hash_equal'=>hash_file('sha256',__DIR__.'/attachment.bin')===hash('sha256',$bytes)];
$observations=['title'=>$selected[0]['title'],'bytes'=>strlen($content),'hex'=>bin2hex($content),'sha256'=>hash('sha256',$content),
'input_after_execute'=>$inputTypeAfter,'default_fetch_type'=>get_debug_type($content),
'empty_type'=>get_debug_type($states[0]['payload']),'missing_type'=>get_debug_type($states[1]['payload']),'sql_states'=>$states,
'driver_buffers_entire_lob'=>true];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-blob-attachment','Прочитать одно вложение с теми же двоичными байтами',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Вложение — Измерение MTR2
Байт в файле — 9
HEX файла — 4d5452320000ff10fe
Тип обычного fetch — string
Переменная после PARAM_LOB — string
Пустое и отсутствующее — SQL: 0 байт / не NULL; NULL / отсутствует
Файл — attachment.bin
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать двоичный файл прибора
Границы решения
Это проверенный малый CLI-файл, не HTTP-загрузка и не тест больших объектов. PARAM_LOB здесь не обещает малой памяти: драйвер буферизует источник и результат. fread поверх уже полученного потока не отменит прежнюю загрузку. Ограничивайте размер и число вложений до дорогого чтения, храните отдельные метаданные, выбирайте содержимое по одному проверенному ID. Для действительно больших файлов рассмотрите хранилище файлов с контролируемыми ссылками. Из браузера нельзя принимать произвольный файловый путь или считать знание ID правом на скачивание.
Попробуйте изменить условие
Поменяйте FE на 7F в source.hex. Достаточно ли проверить, что attachment.bin всё ещё содержит 9 байт?
Разбор
Нет: длина останется 9. Должны измениться последняя пара HEX и SHA-256; сравнение содержимого с подготовленным источником должно пройти для новых байтов. Вложения нельзя проверять только по размеру или расширению имени.
Именованный курсор и изменение текущей строки
Именованный курсор нужен, когда алгоритм уже последовательно обрабатывает результат и должен изменить именно текущую строку. Имя относится к запросу на этом соединении. Оно не является первичным ключом, объектом товара или токеном доступа, который можно сохранить в URL.
| Шаг | Метод или SQL | Что он даёт |
|---|---|---|
| Начать короткую операцию | $db->beginTransaction() | Одна граница изменения и отката |
| Подготовить одну обновляемую таблицу | $db->prepare(SELECT ... FOR UPDATE OF QTY) | PDOStatement с ограниченным набором строк |
| Выполнить SELECT и задать имя | $cursor->execute(); затем setAttribute(PDO::ATTR_CURSOR_NAME, "STOCK_EDIT") | Порядок этой реализации PDO; имя назначено до positioned UPDATE |
| Получить текущую строку | $cursor->fetch(PDO::FETCH_ASSOC) | Положение, которое использует CURRENT OF |
| Изменить её | UPDATE ... WHERE CURRENT OF STOCK_EDIT | Изменяет текущую строку, не все строки SELECT |
| Завершить | closeCursor → commit; при отказе rollback | Имя больше не используется; данные перечитываются отдельно |
В PHP 8.5.11 setter принимает строку длиной до 31 байта; слишком длинное имя вызывает ValueError ещё до серверного вызова. При отказе Firebird действует обычная диагностика PDO. getAttribute(PDO::ATTR_CURSOR_NAME) возвращает назначенное имя или null, если его нет; closeCursor сбрасывает его. Идентификатор нельзя передать через placeholder значения. Используйте короткую постоянную строку, не произвольный ввод посетителя.
Порядок вызовов здесь имеет подтверждённую границу версии. Общая инструкция DSQL требует именованный курсор для positioned UPDATE; в PDO_FIREBIRD 8.5.11 нельзя механически поставить setAttribute до execute SELECT. Реальная подготовка получила SQLSTATE 24000 и код -501: execute пытается предварительно закрыть уже именованный курсор. Рабочий порядок — execute SELECT, затем имя, затем fetch и UPDATE. Это не повод скрывать ошибку или автоматически повторять изменение: нужный порядок показан прямо в исходнике проекта.
Содержимое уже полученного PHP-массива не обновляется автоматически вместе с SQL-строкой. После qty=3→5 переменная $item["qty"] всё ещё хранит 3; новый SELECT подтверждает 5. Именно поэтому в CSV сверки есть разные поля before_qty и after_qty. Не выдавайте значения, рассчитанные только в PHP, за независимое подтверждение записи.
$statement = $db->prepare(
'UPDATE STOCK SET QTY=?, VERSION_NO=VERSION_NO+1 WHERE ID=? AND VERSION_NO=?'
);
$statement->execute([$newQty, $id, $expectedVersion]);
if ($statement->rowCount() !== 1) {
throw new RuntimeException('Строка изменилась или больше недоступна');
}
$statement->closeCursor();Этот альтернативный фрагмент подходит для формы, которую пользователь долго держал открытой: ID выбирает запись, VERSION_NO проверяет известную редакцию. Он не требует прежнего открытого курсора. Права доступа и бизнес-диапазон количества проверяются отдельно в обоих вариантах. Транзакция не должна ждать человека между HTTP-запросами.
Вопрос об UPDATE WHERE CURRENT OF поверх JOIN напоминает о границе обновляемости. Его PSQL-пример не копируется в PHP: наш проект использует DSQL и одну таблицу. Наличие ATTR_CURSOR_NAME не делает любой агрегат или JOIN обновляемым и не обещает блокировку двух редакторов. Для конкурентной видимости и режимов транзакции см. следующую главу.
Применить сверку к текущей строке именованного курсора
Складская сверка уже читает две позиции и получает для каждой утверждённое новое количество. Нужно изменить именно строку, на которой сейчас стоит запрос. Именованный курсор связывает чтение и positioned UPDATE. CSV покажет кабель 3 → 5 и хомут 0 → 1; коробка, не выбранная SQL, останется 9.
- Курсор PDO176_STOCK: SQL выбрал ID 1 и ID 2, ID 3 не выбран
- fetch ID 1: qty 3 → WHERE CURRENT OF → qty 5; PHP item всё ещё хранит 3
- fetch ID 2: qty 0 → WHERE CURRENT OF → qty 1
- Закрыли запросы → commit → новый SELECT: 5, 1, 9 → positions.csv
Минимальный вызов
$db->beginTransaction();
$cursor = $db->prepare('SELECT id,qty FROM stock WHERE id=1 FOR UPDATE OF qty');
$cursor->execute();
$cursor->setAttribute(PDO::ATTR_CURSOR_NAME, 'STOCK_EDIT');
if ($cursor->fetch(PDO::FETCH_ASSOC) !== false) {
$db->exec('UPDATE stock SET qty=5 WHERE CURRENT OF STOCK_EDIT');
}
$cursor->closeCursor();
$db->commit();Курсор — положение чтения SQL-запроса, а не PHP-объект товара. В проверяемом PDO_FIREBIRD ATTR_CURSOR_NAME задаётся объекту PDOStatement после execute SELECT, но до fetch и выполнения positioned UPDATE. После fetch имя STOCK_EDIT указывает на текущую строку; WHERE CURRENT OF использует это положение. Имя задаёт программа, оно не подставляется параметром ?. Все операции выполняются через одно соединение и одну транзакцию. В полном проекте значение количества связано параметром, каждое изменение проверяется по rowCount, а отказ освобождает запросы и вызывает rollback.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Имя активного курсора | PDO176_STOCK |
| Кабель | 3 → 5 |
| Хомут | 0 → 1 |
| Коробка вне выборки | 9 |
| Имя после closeCursor | NULL |
| Файл | positions.csv |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p176Config();$db=p176Connect();$runtime=p176Runtime($db);
function writeCsv176(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=p176OwnName($db,$owned,'TABLE','cursor');$t=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$t.' (ID INT PRIMARY KEY,TITLE VARCHAR(80),QTY INT NOT NULL)');
foreach([[1,'Кабель',3],[2,'Хомут',0],[3,'Коробка',9]] as $v) {p176Command($db,'INSERT INTO '.$t.' (ID,TITLE,QTY) VALUES (?,?,?)',$v);}
$targets=[1=>5,2=>1];$cursor=null;$update=null;$visited=[];$changes=[];$nameBefore=null;$nameAfter=null;
$db->beginTransaction();
try {
$cursor=$db->prepare('SELECT ID AS "id",TITLE AS "title",QTY AS "qty" FROM '.$t.' WHERE ID<=2 ORDER BY ID FOR UPDATE OF QTY');
$cursor->execute();
if(!$cursor->setAttribute(PDO::ATTR_CURSOR_NAME,'PDO176_STOCK')) {throw new RuntimeException('Курсор не получил имя.');}
$nameBefore=$cursor->getAttribute(PDO::ATTR_CURSOR_NAME);
$update=$db->prepare('UPDATE '.$t.' SET QTY=? WHERE CURRENT OF PDO176_STOCK');
while(($item=$cursor->fetch(PDO::FETCH_ASSOC))!==false) {
$id=$item['id'];if(!array_key_exists($id,$targets)) {throw new RuntimeException('В сверке нет ожидаемой позиции.');}
$update->bindValue(1,$targets[$id],PDO::PARAM_INT);$update->execute();
if($update->rowCount()!==1) {throw new RuntimeException('Текущая строка не изменена.');}
$visited[]=$id;$changes[]=[$id,$item['title'],$item['qty'],$targets[$id]];
// PHP-массив item сохраняет прежнее qty; актуальный остаток перечитаем SQL после commit.
$update->closeCursor();
}
if($visited!==[1,2]) {throw new RuntimeException('Прочитан неполный список сверки.');}
p176Release($update);$cursor->closeCursor();$nameAfter=$cursor->getAttribute(PDO::ATTR_CURSOR_NAME);$cursor=null;
$db->commit();
} catch(Throwable $failure) {
p176Release($update);p176Release($cursor);if($db->inTransaction()) {$db->rollBack();}throw $failure;
}
$stored=p176Read($db,'SELECT ID AS "id",TITLE AS "title",QTY AS "qty" FROM '.$t.' ORDER BY ID');
writeCsv176('positions.csv',array_merge([['id','title','before_qty','after_qty']],$changes));
$rows=[['Имя активного курсора',$nameBefore],['Кабель','3 → 5'],['Хомут','0 → 1'],['Коробка вне выборки',$stored[2]['qty']],
['Имя после closeCursor','NULL'],['Файл','positions.csv']];
$checks=['cursor_name'=>$nameBefore==='PDO176_STOCK','two_selected'=>$visited===[1,2],
'old_row_values'=>array_column($changes,2)===[3,0],'commit_readback'=>array_column($stored,'qty')===[5,1,9],
'outside_unchanged'=>$stored[2]['id']===3&&$stored[2]['qty']===9,'name_reset'=>$nameAfter===null,
'transaction_finished'=>!$db->inTransaction(),'artifact_written'=>is_file(__DIR__.'/positions.csv')];
$observations=['changes'=>$changes,'stored'=>$stored,'cursor_before'=>$nameBefore,'cursor_after'=>$nameAfter,
'same_connection_and_transaction'=>true,'statement_released'=>$cursor===null&&$update===null];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-positioned-update','Применить сверку к текущей строке именованного курсора',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Имя активного курсора — PDO176_STOCK
Кабель — 3 → 5
Хомут — 0 → 1
Коробка вне выборки — 9
Имя после closeCursor — NULL
Файл — positions.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать изменения текущих строк CSV
Границы решения
Для обычной формы редактирования проще UPDATE ... WHERE id=? AND version_no=?; именованный курсор полезен при сопровождении уже существующего обхода. Не держите транзакцию открытой, пока человек размышляет над экраном. Проект не проверяет конкуренцию двух редакторов и не обещает блокировку от самого FOR UPDATE. Произвольный JOIN, агрегат или read-only результат не становятся обновляемыми от одного имени. Закрытие курсора сбрасывает имя; после commit/rollback нельзя продолжать прежний positioned UPDATE. Ограничение PHP-драйвера — 31 байт имени; используйте короткую константу приложения.
Попробуйте изменить условие
Нужна такая же сверка из обычной веб-формы после длительного чтения пользователем. Стоит ли сохранять открытый курсор между HTTP-запросами?
Разбор
Нет. Сохраните ID и номер версии в модели формы, затем в новом коротком запросе выполните условный UPDATE по ID/версии. rowCount0 означает конфликт или отсутствие строки; пользователь должен перечитать состояние. Имя курсора не заменяет ключ, версию и проверку права.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


