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

Дерево руководства
Глава 292 · Начало PDO_FIREBIRD → самостоятельные проекты
PDO_FIREBIRD: подключение приложения, квитанция импорта и права читателя
От одного SELECT до каталога CSV, RETURNING собственных ID и отдельной роли; установка, полный DSN и версия клиентского API.
Каталог, квитанция и ограниченный читатель
Страница каталога должна показать конкретные товары, импорт — вернуть номера именно добавленных строк, а отдельный читатель — получить только разрешённые данные. Во всех трёх задачах нужен один понятный путь: приложение выбирает базу, передаёт SQL с параметрами и читает результат. PDO_FIREBIRD связывает общий интерфейс PDO с Firebird; он не превращает SQL разных баз в один универсальный язык.
- DSN: Firebird, адрес базы, UTF8, dialect=3
- PDO-соединение: пользователь и разрешённая роль
- Подготовленная команда: нужные столбцы и значения отдельно от SQL
- Строки результата → каталог CSV, квитанция JSON или проверка доступа
Соединение — объект PHP, через который приложение обращается к базе. Запрос создаёт другой объект, PDOStatement: он хранит подготовленную команду и позволяет прочитать её ответ. Это не строка товара и не вся база в памяти. Перед полным проектом получим одну понятную строку из служебной таблицы Firebird RDB$DATABASE, в которой одна запись:
$db = PDO::connect($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$statement = $db->query('SELECT 7 AS "number" FROM RDB$DATABASE');
$row = $statement->fetch(PDO::FETCH_ASSOC);
echo $row['number']; // 7
$statement->closeCursor();
$statement = null;
$db = null;Переменная $dsn содержит адрес базы; $user и $password — отдельные реквизиты. PDO::connect() — статический вызов: символ :: обращается к классу, а результат сохраняется в $db. Стрелка -> вызывает метод конкретного объекта. query() выполняет SQL, fetch() получает одну строку, FETCH_ASSOC задаёт массив с именованными ключами. Двойные кавычки вокруг SQL-псевдонима "number" сохраняют его регистр. closeCursor() освобождает текущий результат; присваивание null убирает наши ссылки на объекты. Подробнее общие обозначения разобраны в первом уроке PDO, но для данного шага достаточно различать соединение, команду и полученную строку.
Этот минимальный фрагмент предполагает корректные реквизиты и успешный запрос. Ниже самостоятельные проекты добавят собственные таблицы, проверку результата, ограничение выборки, обработку ошибок и удаление своих учебных объектов. Их HTML показывает итог без чтения исходника; CSV и JSON пригодны для дальнейшей обработки. Серверные результаты старых ibase_* примеров не подменяют проверку PDO: начало Firebird остаётся полезным объяснением таблицы и SQL.
PHP-модуль, клиент и сервер: подготовить свою связку
PDO — общий интерфейс; PDO_FIREBIRD — конкретный драйвер; fbclient — клиентская библиотека Firebird; сервер хранит базу и выполняет запросы. Наличие файла базы или работа другого SQL-редактора ещё не означает, что именно запущенный PHP загрузил нужный драйвер.
| Компонент | Проверка и назначение |
|---|---|
| PHP и PDO | Запускается выбранный PHP CLI со своим php.ini. Общий PDO сам по себе не добавляет поддержку Firebird. |
| PDO_FIREBIRD | Имя firebird должно присутствовать в PDO::getAvailableDrivers(). Проверяемый современный профиль использует PHP 8.5.11. |
| fbclient | Библиотека клиента и её зависимости должны находиться загрузчиком и совпадать по архитектуре с PHP. Она отличается от PHP-модуля pdo_firebird. |
| Firebird | В проектах используется отдельный сервер Firebird 5.0.4 и собственная база; профиль и секреты рабочего сайта не меняются. |
| Pdo\Firebird | Подкласс PDO доступен с PHP 8.4 при загруженном драйвере. Его метод API-версии не обращается к серверу. |
$drivers = PDO::getAvailableDrivers();
if (!in_array('firebird', $drivers, true)) {
throw new RuntimeException('Этот PHP не загрузил PDO_FIREBIRD.');
}
echo PHP_VERSION, "\n";
echo php_ini_loaded_file() ?: 'php.ini не загружен';Этот короткий диагностический фрагмент проверяет текущий процесс. CLI и Apache/PHP-FPM могут запускать разные PHP и читать разные ini. Проверка загруженного модуля отвечает на вопрос о клиенте; успешное подключение и полезный SELECT — следующие отдельные проверки. Не размещайте полный phpinfo() с путями и окружением на публичной странице.
# Сборка из исходников PHP: DIR — каталог установленного клиента Firebird.
./configure --with-pdo-firebird[=DIR]
# Пример конкретной формы без необязательного каталога:
./configure --with-pdo-firebirdКвадратные скобки в первой строке обозначают необязательную часть синтаксиса configure, а не символы, которые нужно копировать в команду. С PHP 8.4 расширение использует C++ API Firebird: нужны C++-компилятор и fbclient версии не ниже 3.0. Это минимальное требование сборки, а не обещание совместимости любой комбинации клиента, сервера и PHP. В Windows используют DLL из соответствующего пакета PHP с той же архитектурой и ABI; случайное копирование библиотек в System32 не является рецептом данного проекта.
README каждого ZIP содержит точный рецепт отдельного проверенного профиля: файлы клиента, собственный php.ini, loopback-сервер, база, переменные окружения и запуск. Он дополняет короткую схему выше. Здесь не предлагается заменить PHP действующего сайта или подключиться администратором к произвольной найденной FDB. Учебный helper разрешает работу только со своим профилем и зарегистрированными объектами.
Проблема «драйвер включён в панели, но PDO его не видит» вместе с неверной формой DSN разобрана как пользовательская ситуация в обсуждении Stack Overflow. Решение здесь разделяет загрузку PHP-модуля, доступность клиента и адрес базы; технический контракт берётся из PHP Manual и фактического профиля, а не из комментария к вопросу.
Полная форма DSN и аргументы подключения
DSN описывает, какую базу открыть. Логин и пароль указывают, от чьего имени это сделать. Право прочитать конкретную таблицу проверяет Firebird отдельно. Для PDO_FIREBIRD нельзя переносить имена host и port из DSN другого драйвера: адрес сервера и порт входят в dbname.
$dsn = 'firebird:dbname=db.example.test/3050:/data/SHOP.FDB;charset=UTF8;dialect=3';
$db = PDO::connect($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);| Элемент | Полный договор и пример |
|---|---|
| firebird: | Префикс выбирает драйвер PDO_FIREBIRD. Двоеточие отделяет имя драйвера от его параметров. |
| dbname | Имя/адрес базы. Локальная форма: /data/SHOP.FDB. Сетевая: db.example.test/3050:/data/SHOP.FDB. В сетевой форме путь относится к серверу. |
| charset | Кодировка соединения, например UTF8. Она должна соответствовать задаче обмена текстом; сама настройка не исправляет уже повреждённые или неверно описанные данные. |
| role | Имя SQL-роли для подключения. Роль должна существовать и быть предоставлена пользователю; реквизиты входа она не заменяет. |
| dialect | Диалект 1 или 3; значение по умолчанию 3. Поле DSN поддерживается с PHP 7.4.0. Оно должно согласовываться с базой и смыслом используемых типов. |
| ; | Разделяет элементы DSN. Это не разделитель нескольких SQL-команд в данном контексте. |
// Локальный путь: способ доступа зависит от установленного клиента/провайдера.
$local = 'firebird:dbname=/data/SHOP.FDB';
// Сервер, явный порт и путь на сервере.
$remote = 'firebird:dbname=db.example.test/3050:/data/SHOP.FDB';
// Сервер на localhost, порт Firebird по умолчанию.
$sameMachine = 'firebird:dbname=localhost:/var/lib/firebird/data/SHOP.FDB';
// Только для базы, действительно использующей старый dialect 1; PHP 7.4+.
$oldDialect = 'firebird:dbname=localhost:/data/LEGACY.FDB;charset=UTF8;dialect=1';Эти четыре записи показывают полный набор форм из официальной страницы DSN, но не требуют создавать четыре сервера или менять диалект существующей базы. Dialect 1 — режим совместимости со своими правилами типов, а не «вариант номер один для начинающего». При серверном подключении файл не ищется рядом с PHP-скриптом. Порт HTTP-сайта также не является портом Firebird. В ZIP используются собственная база и отдельный TCP-порт из README.
| Аргумент открытия PDO | Для чего нужен |
|---|---|
| $dsn: string | Проверенная строка маршрута. Для нескольких баз выбирайте целый профиль из разрешённого списка, а не собирайте dbname/role из произвольного запроса посетителя. |
| $username: ?string = null | Имя пользователя базы. В учебном TCP-профиле задаётся явно. |
| $password: ?string = null | Пароль; хранится вне исходника и публичного результата. SensitiveParameter в сигнатуре не заменяет внимательную работу с собственными журналами. |
| $options: ?array = null | Настройки при открытии. Здесь явно задаются ERRMODE_EXCEPTION и отключённая постоянность; драйверные атрибуты допустимы только для Firebird. |
Начиная с PHP 8.4, PDO::connect() возвращает поддерживаемый драйвером подкласс — здесь Pdo\Firebird. new PDO(...) остаётся способом получить базовый PDO. Оба дают общий интерфейс запросов, но отдельные возможности зависят от драйвера. Неудачное открытие выбрасывает PDOException; настройка режима ошибок после подключения не может предотвратить ошибку самого конструктора. Общая сигнатура и дополнительные способы адресации описаны в PDO::__construct() и PDO::connect().
PDO::ATTR_PERSISTENT при открытии просит постоянное соединение, связанное со временем жизни процесса, а не создаёт общий пул для всех процессов. Настройки и незавершённая работа требуют отдельной дисциплины. Наши проекты используют непостоянные соединения и не доказывают повторное использование между HTTP-запросами. Такая проверка требует настоящего веб-работника и отдельного сценария; результаты native ibase_pconnect нельзя автоматически перенести на PDO.
Практика DSN: каталог с явной кодировкой и диалектом и отдельный маршрут с SQL-ролью. В Firebird 4+ существуют default roles, которые могут быть активны без явного role. Поэтому проект прав создаёт обычную, не default-роль; он не делает общего вывода «все роли всегда нужно указывать вручную».
API сборки клиента и версия работающего сервера
Когда два сервера приложения по-разному работают с одной базой, полезно приложить к обращению в поддержку сведения о клиенте. Номер API сборки можно прочитать даже до подключения. Но по нему нельзя узнать версию удалённого Firebird или проверить наличие таблицы.
// PHP 8.4+ и загруженный PDO_FIREBIRD; соединение не требуется.
$apiVersion = Pdo\Firebird::getApiVersion();
echo $apiVersion, "\n";| Часть договора | Значение |
|---|---|
| Сигнатура | public static Pdo\Firebird::getApiVersion(): int |
| Аргументы | Нет. Метод статический: не нужно создавать объект подключения. |
| Возврат | Целое значение FB_API_VER из ibase.h, с которым собран драйвер. Конкретное число относится к выбранной сборке. |
| Версия и отсутствие | Метод и подкласс доступны с PHP 8.4 при PDO_FIREBIRD. Если класс не загружен, вызов недоступен; это не SQL-ошибка и не «сервер вернул false». |
| Граница | Это не номер релиза fbclient, не версия базы и не проверка доступности сервера. |
$details = [
'php' => PHP_VERSION,
'firebird_api' => Pdo\Firebird::getApiVersion(),
'driver_loaded' => in_array('firebird', PDO::getAvailableDrivers(), true),
];
echo json_encode($details, JSON_THROW_ON_ERROR);Первое применение — сравнить наличие клиентского API до получения реквизитов. Второе — добавить ограниченную диагностику к уже работающему экспорту каталога: там отдельно наблюдаются класс подключения, ATTR_DRIVER_NAME и версии клиента/сервера. Не публикуйте вместе с ними DSN с частным путём, пароль или полный дамп phpinfo(). Старая каноническая карточка метода и её два примера с проверкой синтаксиса сохранены; новый проект даёт самостоятельное свидетельство реального запуска.
Каталог по адресу базы и короткая диагностика клиента
Менеджеру нужен короткий список того, что можно отгрузить сейчас. В исходной таблице три позиции: Кабель — 8, Крепёж — 0, Лента O'Reilly — 4. Экран и CSV получают только две доступные позиции. Вместе с результатом сохраняется безопасная диагностика клиента, чтобы отличить проблему модуля PHP от ошибки конкретного SQL.
- Таблица: CAB=8, CLP=0, TAP=4
- WHERE QTY>0 → CAB и TAP; ORDER BY SKU; ROWS 2
- fetch: две строки → числа количества → catalog.csv; API/client/server наблюдаются отдельно
Минимальный вызов
$statement = $db->prepare('SELECT sku, title, qty FROM products WHERE qty > ? ORDER BY sku ROWS 2');
$statement->bindValue(1, 0, PDO::PARAM_INT);
$statement->execute();
while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
echo $row['SKU'], ': ', $row['QTY'], "\n";
}
$statement->closeCursor();WHERE исключает нулевой остаток на стороне базы; ORDER BY задаёт порядок, ROWS 2 ограничивает объём. Знак ? обозначает значение границы, а не часть имени столбца. Без quoted aliases Firebird обычно возвращает имена в верхнем регистре; полный файл явно задаёт "sku", "title", "qty", чтобы контракт CSV не зависел от этого преобразования. Приведение qty к int выполнено только после получения строки и относится к целому количеству, не к произвольным денежным значениям.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Драйвер | firebird |
| Класс соединения | Pdo\Firebird |
| Отобрано товаров | 2 |
| Каталог | Кабель: 8; Лента O'Reilly: 4 |
| Нулевой остаток | не включён |
| Файл | catalog.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 appCsv176(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);}
}
// PDO::connect выбирает подкласс; логин и пароль остаются отдельно от DSN.
p176Close($db);
$dsn='firebird:dbname='.$config['host'].'/'.$config['port'].':'.$config['database'].';charset=UTF8;dialect=3';
$db=PDO::connect($dsn,$config['user'],$config['password'],[
PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION,PDO::ATTR_PERSISTENT=>false]);
$name=p176OwnName($db,$owned,'TABLE','catalog');$table=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$table.' (ID INTEGER PRIMARY KEY,SKU VARCHAR(12) NOT NULL,
TITLE VARCHAR(80) CHARACTER SET UTF8 NOT NULL,QTY INTEGER NOT NULL)');
foreach ([[1,'CAB','Кабель',8],[2,'CLP','Крепёж',0],[3,'TAP',"Лента O'Reilly",4]] as $item) {
p176Command($db,'INSERT INTO '.$table.' (ID,SKU,TITLE,QTY) VALUES (?,?,?,?)',$item);
}
$statement=$db->prepare('SELECT SKU AS "sku",TITLE AS "title",QTY AS "qty"
FROM '.$table.' WHERE QTY>? ORDER BY SKU ROWS 2');
try {
$statement->bindValue(1,0,PDO::PARAM_INT);$statement->execute();$raw=[];$catalog=[];
while (($row=$statement->fetch(PDO::FETCH_ASSOC))!==false) {
if (!is_array($row)) {throw new RuntimeException('Ожидалась строка каталога.');}
$raw[]=$row;$catalog[]=['sku'=>$row['sku'],'title'=>$row['title'],'qty'=>(int)$row['qty']];
}
} finally {p176Release($statement);}
$api=Pdo\Firebird::getApiVersion();$driver=$db->getAttribute(PDO::ATTR_DRIVER_NAME);
$identity=p176Read($db,'SELECT CURRENT_CONNECTION AS "connection_id",
RDB$GET_CONTEXT(\'SYSTEM\',\'NETWORK_PROTOCOL\') AS "protocol" FROM RDB$DATABASE')[0];
appCsv176('catalog.csv',array_merge([['sku','title','qty']],array_map('array_values',$catalog)));
$expected=[['sku'=>'CAB','title'=>'Кабель','qty'=>8],['sku'=>'TAP','title'=>"Лента O'Reilly",'qty'=>4]];
$rows=[['Драйвер',$driver],['Класс соединения',get_class($db)],['Отобрано товаров',count($catalog)],
['Каталог',"Кабель: 8; Лента O'Reilly: 4"],['Нулевой остаток','не включён'],['Файл','catalog.csv']];
$checks=['firebird_loaded'=>in_array('firebird',PDO::getAvailableDrivers(),true),
'driver_name'=>$driver==='firebird','subclass'=>$db instanceof Pdo\Firebird,
'api_integer'=>is_int($api)&&$api>0,'api_matches_runtime'=>$api===$runtime['api_version'],
'exact_catalog'=>$catalog===$expected,'two_rows'=>count($raw)===2,
'null_stock_excluded'=>!in_array('CLP',array_column($catalog,'sku'),true),
'sql_order'=>array_column($catalog,'sku')===['CAB','TAP'],
'tcp_connection'=>trim((string)$identity['protocol'])==='TCPv4',
'live_attachment'=>(int)$identity['connection_id']>0,'request_released'=>$statement===null,
'no_manual_transaction'=>$db->inTransaction()===false,
'artifact_written'=>is_file(__DIR__.'/catalog.csv')];
$observations=['raw_rows'=>$raw,'catalog'=>$catalog,'client_api'=>$api,
'connection_class'=>get_class($db),'protocol'=>trim((string)$identity['protocol']),
'live_attachment_id'=>(int)$identity['connection_id'],
'sql_projection'=>['sku','title','qty'],'sql_limit'=>2];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-connection-catalog','Каталог по адресу базы и короткая диагностика клиента',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Драйвер — firebird
Класс соединения — Pdo\Firebird
Отобрано товаров — 2
Каталог — Кабель: 8; Лента O'Reilly: 4
Нулевой остаток — не включён
Файл — catalog.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать каталог CSV
Границы решения
Проверка API не заменяет SQL и не определяет версию удалённой базы. Этот проект делает настоящий TCP-запрос; повторное использование HTTP-соединений он не проверяет. Предел ROWS — часть учебного экрана, а не обещание полного экспорта большого каталога. Не снимайте ограничение ради подсчёта всех строк: для другого отчёта напишите отдельный SQL.
Попробуйте изменить условие
Покажите товары с остатком не менее 5 и сохраните прежний порядок. Что изменится в CSV?
Разбор
Замените QTY>? на QTY>=? и передайте 5. Останется CAB,Кабель,8. В объяснении и ожидании изменится назначение фильтра; кодировка, адрес базы и API-версия от количества строк не меняются.
Квитанция импорта с номерами именно добавленных строк
Поставщик прислал небольшой JSON с тремя позициями. После загрузки нужно вернуть квитанцию: какая позиция получила какой номер и что действительно сохранено. Номер нельзя угадать через MAX(id), а слова null и пустая строка не должны превратиться друг в друга. Приложенный incoming.json задаёт все входные значения; receipt.json содержит проверяемый результат.
- incoming.json: CAB/null, CLP/"", TAP/"Проверено"
- prepare один раз → bindValue/execute/RETURNING для каждой строки → commit
- SELECT сохранённых строк → сравнение с RETURNING → receipt.json с ID 1, 2, 3
Минимальный вызов
$statement = $db->prepare('INSERT INTO items (title) VALUES (?) RETURNING id AS "id"');
$statement->bindValue(1, "Кабель O'Reilly", PDO::PARAM_STR);
$statement->execute();
$inserted = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($inserted)) { throw new RuntimeException('Нет строки RETURNING.'); }
echo $inserted['id'];
$statement->closeCursor();В таблице полного проекта ID объявлен GENERATED BY DEFAULT AS IDENTITY: база назначает номер. RETURNING описывает поля ответа текущей вставки; execute выполняет команду, fetch читает её ответ. Полный файл готовит запрос один раз, привязывает значения каждой строки и закрывает результат перед следующим execute. Одна транзакция сохраняет всю поставку; затем отдельный SELECT сверяет её с квитанцией.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Принято позиций | 3 |
| Номера из RETURNING | 1, 2, 3 |
| Название первой позиции | Кабель O'Reilly |
| Примечания | null; пустая строка; Проверено |
| Повторное чтение | совпадает с квитанцией |
| Файл | receipt.json |
Самостоятельный проект, исходник и архив
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];
try {
$config=p176Config();$db=p176Connect();$runtime=p176Runtime($db);
$input=json_decode(file_get_contents(__DIR__.'/incoming.json'),true,512,JSON_THROW_ON_ERROR);
if (!is_array($input)||count($input)!==3) {throw new RuntimeException('Нужны три позиции учебной поставки.');}
$name=p176OwnName($db,$owned,'TABLE','imports');$table=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$table.' (ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
SKU VARCHAR(12) NOT NULL UNIQUE,TITLE VARCHAR(80) CHARACTER SET UTF8 NOT NULL,
QTY INTEGER NOT NULL,NOTE VARCHAR(80) CHARACTER SET UTF8)');
$insert=null;$received=[];$rawReturned=[];
$db->beginTransaction();
try {
$insert=$db->prepare('INSERT INTO '.$table.' (SKU,TITLE,QTY,NOTE) VALUES (:sku,:title,:qty,:note)
RETURNING ID AS "id",SKU AS "sku",TITLE AS "title",QTY AS "qty",NOTE AS "note"');
foreach ($input as $item) {
if (!is_array($item)||!is_int($item['qty'])||!array_key_exists('note',$item)) {
throw new RuntimeException('Неверная строка входного документа.');
}
$insert->bindValue(':sku',$item['sku'],PDO::PARAM_STR);
$insert->bindValue(':title',$item['title'],PDO::PARAM_STR);
$insert->bindValue(':qty',$item['qty'],PDO::PARAM_INT);
$insert->bindValue(':note',$item['note'],$item['note']===null?PDO::PARAM_NULL:PDO::PARAM_STR);
$insert->execute();$row=$insert->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {throw new RuntimeException('INSERT не вернул квитанцию.');}
$rawReturned[]=$row;
$received[]=['id'=>(int)$row['id'],'sku'=>$row['sku'],'title'=>$row['title'],
'qty'=>(int)$row['qty'],'note'=>$row['note']];
$insert->closeCursor();
}
p176Release($insert);$db->commit();
} finally {
p176Release($insert);if ($db->inTransaction()) {$db->rollBack();}
}
$stored=p176Read($db,'SELECT ID AS "id",SKU AS "sku",TITLE AS "title",QTY AS "qty",NOTE AS "note"
FROM '.$table.' ORDER BY ID ROWS 3');
$normalized=array_map(static fn(array $r):array=>['id'=>(int)$r['id'],'sku'=>$r['sku'],
'title'=>$r['title'],'qty'=>(int)$r['qty'],'note'=>$r['note']],$stored);
$receipt=['accepted'=>count($received),'items'=>$received];
$json=json_encode($receipt,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR)."\n";
file_put_contents(__DIR__.'/receipt.json',$json);
$rows=[['Принято позиций',count($received)],['Номера из RETURNING',implode(', ',array_column($received,'id'))],
['Название первой позиции',$received[0]['title']],['Примечания','null; пустая строка; Проверено'],
['Повторное чтение','совпадает с квитанцией'],['Файл','receipt.json']];
$checks=['three_returned'=>count($received)===3,'own_identity_values'=>array_column($received,'id')===[1,2,3],
'returned_skus'=>array_column($received,'sku')===array_column($input,'sku'),
'returned_titles'=>array_column($received,'title')===array_column($input,'title'),
'returned_quantities'=>array_column($received,'qty')===[3,0,2],
'sql_null_preserved'=>$received[0]['note']===null,'empty_preserved'=>$received[1]['note']==='',
'text_preserved'=>$received[2]['note']==='Проверено','stored_matches'=>$normalized===$received,
'statement_released'=>$insert===null,'transaction_finished'=>$db->inTransaction()===false,
'artifact_roundtrip'=>json_decode($json,true,512,JSON_THROW_ON_ERROR)===$receipt];
$observations=['incoming'=>$input,'raw_returning'=>$rawReturned,'saved_after_commit'=>$stored,
'receipt'=>$receipt,'id_policy'=>'identity of this newly created table; not global MAX'];
} finally {
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-parameter-receipt','Квитанция импорта с номерами именно добавленных строк',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Принято позиций — 3
Номера из RETURNING — 1, 2, 3
Название первой позиции — Кабель O'Reilly
Примечания — null; пустая строка; Проверено
Повторное чтение — совпадает с квитанцией
Файл — receipt.json
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать квитанцию JSON
Границы решения
Номера 1, 2, 3 относятся только к новой собственной таблице этого запуска. Identity не обещает отсутствие пропусков и не служит счётчиком успешных заказов. Проект не меняет структуру чужой таблицы с уже существующим генератором. JSON выбран потому, что он явно различает null и пустую строку; для CSV потребуется договор о представлении отсутствующих значений. Для больших импортов нужен отдельный план порций и восстановления после отказа.
Попробуйте изменить условие
Измените количество третьей позиции на 5 и сделайте её примечание null. Какие поля квитанции должны измениться?
Разбор
У третьего элемента items изменятся qty и note; id останется номером, назначенным этой вставке. Проверки должны сравнить новые значения и повторное чтение после commit, а не только успешный execute. Если любой элемент нарушает договор, откатывается вся поставка.
Каталог с ролью читателя и подтверждённым запретом изменения
Сервису отчётов нужен доступ к каталогу, но он не должен менять остатки. Проект создаёт собственного читателя и обычную SQL-роль. Один и тот же пользователь сначала входит без активной роли и получает отказ SELECT, затем подключается с role в DSN и читает Кабель: 8. Попытка UPDATE остаётся запрещённой. CSV показывает проверенные границы, а не просто успешную выдачу GRANT.
- Своя таблица: Кабель=8; ROLE получает только SELECT; USER получает обычную ROLE
- Вход USER без role: принят, SELECT запрещён → новое подключение с role: SELECT разрешён
- UPDATE запрещён → администратор подтверждает qty=8 → permissions.csv → cleanup USER/ROLE/TABLE
Минимальный вызов
$dsn = 'firebird:dbname=127.0.0.1/9776:/data/learning.fdb;charset=UTF8;dialect=3;role=REPORT_READER';
$reader = PDO::connect($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$statement = $reader->query('SELECT CURRENT_ROLE AS "role" FROM RDB$DATABASE');
echo $statement->fetchColumn();
$statement->closeCursor();role — часть DSN и выбор роли при открытии; SQL GRANT отдельно выдаёт этой роли SELECT и разрешает пользователю её использовать. CURRENT_ROLE показывает явно активированную роль, но не полный список default roles. В полном проекте имя роли и пользователя случайное, своё и проверенное; пароль создаётся при запуске и не попадает в результат. Дополнительные подключения закрываются до удаления пользователя, роли и таблицы.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Вход без роли | успешен |
| SELECT без роли | отказ |
| Каталог с ролью | Кабель: 8 |
| UPDATE с ролью читателя | отказ |
| Остаток в базе | 8 |
| Файл | permissions.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 appCsv176(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);}
}
$reader=null;$without=null;
$name=p176OwnName($db,$owned,'TABLE','role_items');$table=p176Qualified($name);
p176Ddl($db,'CREATE TABLE '.$table.' (ID INTEGER PRIMARY KEY,TITLE VARCHAR(80) CHARACTER SET UTF8,QTY INTEGER)');
p176Command($db,'INSERT INTO '.$table.' (ID,TITLE,QTY) VALUES (?,?,?)',[1,'Кабель',8]);
$roleName=p176OwnName($db,$owned,'ROLE','reader_role');$role=p176Qualified($roleName);
p176Ddl($db,'CREATE ROLE '.$role);
$userName=p176OwnName($db,$owned,'USER','reader_user');$user=p176Qualified($userName);
$password=bin2hex(random_bytes(12));
file_put_contents(__DIR__.'/secret_values.json',json_encode([
'schema'=>'aen-firebird176-private-secrets-v1','values'=>[$password]],JSON_THROW_ON_ERROR));
$created=p176Capture(static fn()=>$db->exec('CREATE USER '.$user.' PASSWORD '.$db->quote($password)),$db);
if ($created['exception']) {throw new RuntimeException('Не удалось создать собственного читателя.');}
p176Ddl($db,'GRANT SELECT ON '.$table.' TO ROLE '.$role);
p176Ddl($db,'GRANT '.$role.' TO USER '.$user); // обычная роль, без DEFAULT
try {
$without=p176Connect($userName,$password);
$identityWithout=p176Read($without,'SELECT CURRENT_USER AS "user_name",CURRENT_ROLE AS "role_name" FROM RDB$DATABASE')[0];
$readDenied=p176Capture(static fn()=>p176Read($without,'SELECT TITLE FROM '.$table.' WHERE ID=1'),$without);
p176Close($without);
$reader=p176Connect($userName,$password,$roleName);
$identityWith=p176Read($reader,'SELECT CURRENT_USER AS "user_name",CURRENT_ROLE AS "role_name" FROM RDB$DATABASE')[0];
$catalog=p176Read($reader,'SELECT TITLE AS "title",QTY AS "qty" FROM '.$table.' WHERE ID=1');
$updateDenied=p176Capture(static fn()=>p176Command($reader,'UPDATE '.$table.' SET QTY=99 WHERE ID=1'),$reader);
} finally {p176Close($without);p176Close($reader);}
$stored=p176Read($db,'SELECT QTY AS "qty" FROM '.$table.' WHERE ID=1');
$readSafe=$readDenied;unset($readSafe['value']);$updateSafe=$updateDenied;unset($updateSafe['value']);
appCsv176('permissions.csv',[
['stage','login','select','update','qty'],
['without_role','accepted','denied','not_attempted',8],
['reader_role','accepted','accepted','denied',8]]);
$rows=[['Вход без роли','успешен'],['SELECT без роли','отказ'],['Каталог с ролью','Кабель: 8'],
['UPDATE с ролью читателя','отказ'],['Остаток в базе',(int)$stored[0]['qty']],['Файл','permissions.csv']];
$checks=['same_login_without'=>trim($identityWithout['user_name'])===$userName,
'ordinary_role_inactive'=>trim($identityWithout['role_name'])==='NONE',
'select_rejected'=>$readDenied['exception']===true&&$readDenied['value']===false,
'select_diagnosed'=>$readDenied['sqlstate']==='28000'&&$readDenied['driver_code']===-551&&$readDenied['message_present'],
'same_login_with'=>trim($identityWith['user_name'])===$userName,
'dsn_role_active'=>trim($identityWith['role_name'])===$roleName,
'selected_one'=>count($catalog)===1,'selected_title'=>$catalog[0]['title']==='Кабель',
'selected_quantity'=>(int)$catalog[0]['qty']===8,
'update_rejected'=>$updateDenied['exception']===true&&$updateDenied['value']===false,
'update_diagnosed'=>$updateDenied['sqlstate']==='28000'&&$updateDenied['driver_code']===-551&&$updateDenied['message_present'],
'quantity_preserved'=>(int)$stored[0]['qty']===8,'user_connections_released'=>$reader===null&&$without===null,
'secret_length'=>strlen($password)===24,'artifact_written'=>is_file(__DIR__.'/permissions.csv')];
$observations=['identity_without_role'=>['same_user'=>trim($identityWithout['user_name'])===$userName,
'role'=>'NONE'],'identity_with_role'=>['same_user'=>trim($identityWith['user_name'])===$userName,
'expected_role_active'=>trim($identityWith['role_name'])===$roleName],
'read_denied'=>$readSafe,'allowed_catalog'=>$catalog,'update_denied'=>$updateSafe,
'quantity_after'=>(int)$stored[0]['qty'],'grant_kind'=>'ordinary role; not DEFAULT'];
} finally {
if (isset($without)) {p176Close($without);}
if (isset($reader)) {p176Close($reader);}
if ($db instanceof PDO) {
p176Rollback($db);
foreach ($owned as $object) {
if ($object['type']!=='USER') {continue;}
// Отдельная явная граница для собственного пользователя security-базы.
$db->beginTransaction();
try {
if (p176ObjectExists($db,'USER',$object['name'])) {
$db->exec('DROP USER '.p176Qualified($object['name']));
}
$db->commit();
} catch (Throwable $failure) {
if ($db->inTransaction()) {$db->rollBack();}
throw $failure;
}
}
}
if ($db instanceof PDO) {try {$removed=p176Cleanup($db,$owned);} finally {p176Close($db);}}
}
p176Report('pdo176-role-route','Каталог с ролью читателя и подтверждённым запретом изменения',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Вход без роли — успешен
SELECT без роли — отказ
Каталог с ролью — Кабель: 8
UPDATE с ролью читателя — отказ
Остаток в базе — 8
Файл — permissions.csv
Распакуйте весь ZIP и прочитайте README.txt. example.php содержит прикладную логику, lab.php — работу с отдельной базой и собственным реестром объектов. После выполнения откройте result.html; JSON и полезный файл доступны ниже.
Открыть результат выполнения · Посмотреть данные JSON · Скачать проверку доступа CSV
Границы решения
Это настоящий TCP-вход в отдельный учебный сервер, не веб-логин и не проверка embedded-доступа к файлу. Администратор нужен только для подготовки собственных объектов. В Firebird 4+ default roles могут активироваться без поля role, поэтому здесь намеренно применяется обычный GRANT. Ошибку входа, отказ прав и неверный SQL нельзя считать одним результатом. secret_values.json — частный файл проверки пароля, его нельзя публиковать или добавлять к выдаваемым результатам. Удаление собственного пользователя завершается отдельной явной транзакцией: beginTransaction → DROP USER → commit. В исходнике PHP 8.5.11 автоматическая команда использует commit retaining, а явный commit — полное завершение транзакции; закрытие соединения также предусматривает полный commit. Поэтому retaining не объявляется единственной причиной остатка пользователя. Проверка отсутствия в том же соединении оказалась недостаточной: итог удаления дополнительно проверяется из новой внешней сессии. Исходник границ транзакции PDO_FIREBIRD
Попробуйте изменить условие
Почему нельзя заменить роль читателя администратором, сохранив те же проверки?
Разбор
У администратора другие права: успешный SELECT уже не докажет, что роль работает, а UPDATE может изменить остаток. Проверяется именно ограниченный пользователь, обычная роль с SELECT, новое подключение и сохранённое значение после ожидаемого отказа.
Параметры, квитанция и границы результата
Подготовленный запрос разделяет SQL и значения. Маркер может обозначать название товара или его количество, но не имя таблицы, столбца или направление ORDER BY. Для таких частей выбирают заранее разрешённую SQL-форму. Разница между bindValue и bindParam важна при повторном выполнении: первый привязывает переданное значение, второй — переменную по ссылке. Проект импорта использует явные bindValue для каждой строки.
$statement = $db->prepare(
'INSERT INTO items (title, note) VALUES (:title, :note) RETURNING id AS "id"'
);
$statement->bindValue(':title', "Кабель O'Reilly", PDO::PARAM_STR);
$statement->bindValue(':note', null, PDO::PARAM_NULL);
$statement->execute();
$inserted = $statement->fetch(PDO::FETCH_ASSOC);
if (!is_array($inserted)) {
throw new RuntimeException('Не получена строка вставки.');
}
$statement->closeCursor();RETURNING относится к этой операции INSERT: не нужно искать MAX(id) или читать глобальный счётчик после чужой записи. Полный проект квитанции показывает DDL identity, три входные строки и повторное чтение сохранённых данных. Старые схемы с последовательностями/триггерами требуют знания своего DDL; этот пример не добавляет identity в чужую таблицу. Пользовательская проблема такого импорта прочитана в обсуждении PDO и Firebird.
При ERRMODE_EXCEPTION SQL-отказ обрабатывается как PDOException. exception->errorInfo содержит SQLSTATE, код драйвера и сообщение; они не обязаны совпадать с кодами native ibase_* или другого PDO-драйвера. Посетителю нужен безопасный итог, а не внутренний путь и текст SQL. Не ловите любой Throwable, чтобы объявить ожидаемый отказ прав: сначала сопоставьте фактическую причину с контрактом конкретного сценария.
Пустая выборка, SQL NULL и ошибка — разные исходы. Успешный fetch(PDO::FETCH_ASSOC) возвращает массив, конец — false; null внутри массива остаётся значением столбца. Число строк SELECT не следует получать через переносимое обещание rowCount: если нужен счётчик, выполняют отдельный COUNT(*) с тем же фильтром. Если нужны только несколько товаров, ограничивают SQL и выбирают только используемые столбцы, а не загружают всё для последующей фильтрации PHP.
Освобождение результата не сохраняет данные и не откатывает их: closeCursor() относится к statement, commit()/rollBack() — к транзакции. Все активные statements и дополнительные соединения должны быть завершены до удаления учебных объектов. В следующей главе результатов PDO_FIREBIRD разобраны даты, точные суммы, BLOB и курсор; затем явные транзакции связывают несколько изменений в одну операцию.
Полный маршрут версий и старых имён констант
У старого приложения и новой сборки могут быть одинаковые слова Firebird и PDO, но разные доступные классы и клиентские требования. Сверяйте версию PHP, расширение и фактическую библиотеку отдельно. Снятие native InterBase из основного PHP не означает удаления PDO_FIREBIRD: это разные расширения.
| Версия / имя | Что нужно сохранить при чтении старого кода |
|---|---|
| Историческая страница PDO_FIREBIRD DSN | PHP Manual помечает её PECL PDO_FIREBIRD >= 0.1.0. Современный путь установки описан вместе с исходниками PHP; это не совет искать случайный старый PECL-бинарник. |
| PHP 7.4+ | Параметр DSN dialect позволяет явно выбрать 1 или 3; без него используется 3. |
| PHP 8.4+ | Появились Pdo\Firebird и getApiVersion; PDO::connect возвращает поддержанный подкласс. Драйвер использует C++ API, требует C++ compiler и fbclient >= 3.0. |
| PHP 8.5+ | Старые PDO::FB_ATTR_* из таблицы ниже устарели. Для нового кода используйте соответствующие Pdo\Firebird константы. |
| Проверяемый профиль проекта | PHP 8.5.11 + Firebird 5.0.4. Это закреплённая связка, а не заявление «самые новые версии». Данные реального запуска помещаются в JSON проекта. |
| Историческое имя | Современное имя и назначение |
|---|---|
| PDO::FB_ATTR_DATE_FORMAT | Pdo\Firebird::ATTR_DATE_FORMAT — формат выдаваемой даты. |
| PDO::FB_ATTR_TIME_FORMAT | Pdo\Firebird::ATTR_TIME_FORMAT — формат выдаваемого времени. |
| PDO::FB_ATTR_TIMESTAMP_FORMAT | Pdo\Firebird::ATTR_TIMESTAMP_FORMAT — формат выдаваемой отметки даты/времени. |
Эти константы существуют только при загруженном драйвере и применяются именно к его соединению. Перед передачей драйверной настройки общему компоненту проверьте PDO::ATTR_DRIVER_NAME; одинаковые числовые значения у разных драйверов не означают одинаковые возможности. Полная практика трёх форматов находится в экспорте дат, а собственные атрибуты транзакций — в контракте класса Pdo\Firebird.
По умолчанию режима транзакций нельзя делать вывод только из слова default в короткой таблице. Автоматическая работа соединения и явно начатая транзакция — разные контексты; исходник выбранной версии и подготовка стенда сверяются в главе транзакций. Примеры согласованного чтения задают режим явно. Не переносите настройки, коды ошибок или наблюдения PDO_CUBRID из предыдущего пакета на Firebird.
Различить загрузку клиента, вход, права и пустую выборку
Когда каталог не открылся, сначала определите, на каком шаге произошёл отказ. Иначе легко начать менять права таблицы, хотя PHP ещё не загрузил клиентский модуль, или менять пароль из-за неверного пути базы.
| Наблюдение | Какое различие проверять |
|---|---|
| Нет firebird в PDO::getAvailableDrivers() | Этот PHP не загрузил PDO_FIREBIRD. Проверить исполняемый файл, свой php.ini, архитектуру и клиентские зависимости; SQL ещё не выполнялся. |
| PDOException при открытии | Проверить безопасно выбранный профиль, сетевой адрес, путь на сервере и реквизиты. Не печатать посетителю raw error с внутренним путём. |
| Вход принят, SELECT запрещён | Пользователь существует, но для объекта недостаточно прав. Сопоставить GRANT, активную роль и новый контекст подключения. |
| SELECT успешен, строк нет | Это может быть правильный результат WHERE. fetch=false после успешного чтения не означает потерю соединения. |
| Получена строка с note=null | Столбец содержит SQL NULL; строка существует. Не проверять её наличие через if($row["note"]). |
| Нужное значение отсутствует в JSON после чтения | Проверить точные имена/регистр столбцов и явную проекцию. Перекодирование «наугад» не заменяет проверку кодировки и типов. |
В обсуждении роли и отказа доступа пользователь выдал права, но фактический CURRENT_ROLE был NONE: отображение имени в интерфейсе не доказывало активную роль. Наш проект ограниченного читателя проверяет сам факт входа, выбранную обычную роль, разрешённый SELECT, запрещённый UPDATE и сохранённый остаток. Здесь используется таблица, а не копируется процедура из вопроса.
Попробуйте объяснить четыре разных результата без кода: «драйвер не загружен», «пользователь вошёл, но роль не действует», «фильтр не нашёл товар», «примечание найденного товара отсутствует». Затем покажите, какая именно проверка каждого проекта подтверждает нужный вывод. Такой разбор помогает не превращать любой отказ в сообщение «база недоступна».
От каталога к точным результатам и транзакции
Теперь приложение умеет выбрать базу, передать значения отдельно от SQL и подтвердить права конкретного читателя. Следующий шаг — сохранить точность результата: даты, большие числа, BLOB и курсор. Затем транзакции PDO_FIREBIRD объединят склад и резерв, объяснят видимость изменений и READ ONLY. Общие контракты остаются в справочнике PDO; native роли Firebird полезны для сравнения серверных прав, но не заменяют драйверные наблюдения.
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


