Улучшения PHP 8.4 при работе с современными версиями Firebird

Симонов Денис версия 1.0 от 12.09.2024

Этот материал был создан при поддержке и спонсорстве компании iBase.ru, которая разрабатывает инструменты Firebird SQL для предприятий и предоставляет сервис технической поддержки для Firebird SQL.

Материал выпущен под лицензией Public Documentation License https://www.firebirdsql.org/file/documentation/html/en/licenses/pdl/public-documentation-license.html

Предисловие

Предстоящий релиз PHP 8.4, который должен выйти в конце 2024 года, содержит множество новых функций и улучшений, главные из которых обработчики свойств (property hooks) и поддержка HTML5 а расширении DOM.

В настоящее для тестирования доступна версия PHP 8.4 Beta 4. Скачать готовые сборки для тестирования можно по ссылке 8.4.0beta5

Разработчикам использующим СУБД Firebird будут интересны новые возможности работы с этой СУБД.

Историческая справка

Как известно, начиная с PHP 7.4 основным и единственным драйвером для работы с Firebird являет расширение pdo_firebird. Расширение Firebird/InterBase было перемещено в PECL и не поставляется в составе PHP.

В 2016 году я занимался написанием серии статей по работе с СУБД Firebird из различных языков программирования, которые затем легли в основу Firebird 3.0 Developer’s Guide (Russian) и Firebird 3.0 Developer’s Guide (English). В то время актуальными были PHP 7.0 и Firebird 3.0. В одной из этих статей рассказывалось о том как работать с Firebird из PHP, в ней же проводилось сравнение расширений pdo_firebird и Firebird/InterBase. Там же я отметил, что почти все фреймворки и ORM использовали PDO, поскольку он предоставляет унифицированный интерфейс для работы с различными СУБД. Сам же я практически не использовал pdo_firebird в своих разработках, поскольку в то время оно содержало множество ошибок. Расширение Firebird/InterBase было намного стабильней. Но уже тогда я понимал, что будущее за PDO и надо что-то менять.

Однажды на одном популярном форуме была создана тема, в которой обсуждались ошибке в драйвере pdo_firebird, и один из участников форума набрался смелости и исправил несколько ошибок, сделав соответствующие Pull Requests. В эту тему продолжали публиковать сообщения об ошибках с надеждой, что он их исправит. Тогда я решил присоединиться и тоже попробовать, что-то исправить и мне это удалось. Вот краткий перечень того что было исправлено мной:

  • утечка памяти при работе с типом данных BLOB;

  • поддержка типа данных BOOLEAN для входных параметров запросов;

  • поддержка 1 sql диалекта;

  • улучшенный разбор именованных параметров в операторе EXECUTE BLOCK.

Поэтому, когда команда разработчиков PHP приняла решение об исключении расширения Firebird/InterBase из состава PHP, я не сильно расстроился. К тому времени расширение pdo_firebird уже стало стабильным, и я с легкостью заменил им расширение Firebird/InterBase.

Работа с Firebird в настоящее время

Как известно в Firebird 4.0 был добавлены новые типы данных:

  • DECFLOAT(16) и DECFLOAT(34);

  • TIME WITH TIME ZONE и TIMESTAMP WITH TIME ZONE;

  • INT128, который лежит в основе NUMERIC(38, x) и DECIMAL(38, x).

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

С TIME WITH TIME ZONE и TIMESTAMP WITH TIME ZONE дела обстоят сложнее. Дело в том, что до введения этих типов данных контекстные переменные CURRENT_TIME и CURRENT_TIMESTAMP возвращали значения типов TIME WITHOUT TIME ZONE и TIMESTAMP WITHOUT TIME ZONE, хотя это противоречило стандарту SQL, теперь же они возвращают TIME WITH TIME ZONE и TIMESTAMP WITH TIME ZONE. Для возврата значений без информации о часовых поясах предусмотрены контекстные переменные LOCALTIME и LOCALTIMESTAMP. Поэтому те запросы, которые раньше работали корректно возвращают новые типы данных, которые неизвестны драйверу pdo_firebird.

Попробуйте запустить следующий пример:

Скрипт index_ts.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        CURRENT_TIMESTAMP AS TS_TZ,
        LOCALTIMESTAMP AS TS,
        CURRENT_TIME AS T_TZ,
        LOCALTIME AS T
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Result:
array(4) {
  ["TS_TZ"]=>
  NULL
  ["TS"]=>
  string(19) "2024-09-11 15:50:21"
  ["T_TZ"]=>
  NULL
  ["T"]=>
  string(8) "15:50:21"
}

То есть для типов с часовыми поясами всегда возвращается значение null, поскольку драйвер ничего не знает о новых типах данных.

Ещё хуже обстоят дела при использовании NUMERIC и DECIMAL. Дело в том, что при вычислении агрегатных функций SUM и AVG, а также при умножении, возвращается результат с наиболее широким типом данных. До Firebird 4.0 для типов NUMERIC и DECIMAL самым вместительным был тип BIGINT, что позволяло считать такие числа с точностью до 18 знаков. Начиная с Firebird 4.0 для NUMERIC и DECIMAL наиболее вместительным стал тип INT128. Это позволяет считать числа с точностью до 38 знаков. Таким образом, если ваши исходные типы данных были NUMERIC(18, x), то после этих операций они расширяются до NUMERIC(38, x). Но pdo_firebird ничего не знает о новых типах данных.

Попробуйте запустить следующий пример:

Скрипт index_n.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        SUM(SALARY) AS SUM_SALARY,
        CAST(SUM(SALARY) AS NUMERIC(18, 2)) AS SUM_SALARY_2
    FROM EMPLOYEE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Result:
array(2) {
  ["SUM_SALARY"]=>
  string(4) "0.01"
  ["SUM_SALARY_2"]=>
  string(11) "16203468.02"
}

В данном случае вместо null вообще выведено непонятное число, что ещё хуже. Следует отметить, что поля SUM_SALARY_2, которое было искусственно преобразовано к типу NUMERIC(18, 2), результат верный.

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

Скрипт index_df.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        QUANTIZE(12354.678, 123.54) AS DF,
        CAST(QUANTIZE(12354.678, 123.54) AS DOUBLE PRECISION) AS D
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Result:
array(2) {
  ["DF"]=>
  NULL
  ["D"]=>
  string(8) "12354.68"
}

Что же делать в данном случае? На самом деле разработчики Firebird 4.0 прекрасно понимали, что новые типы в драйверах появятся не сразу, а потому позаботились о том чтобы эту проблему можно было решить одним из следующих способов:

  • установить параметр DataTypeCompatibility в значение '3.0' в firebird.conf или database.conf;

  • установить привязку новых типов данных к одному из тех, что поддерживается драйвером с помощью оператора SET BIND OF;

  • установить привязку новых типов данных к одному из тех, что поддерживается драйвером с помощью тега isc_dpb_set_bind.

Драйвер pdo_firebird не позволяет самостоятельно конструировать буфер параметров соединения, поэтому третий вариант не подходит. Рассмотрим оставшиеся два.

Параметр DataTypeCompatibility

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

На сегодняшний день он может принимать два значения "2.5" и "3.0". Все запросы на сервере будут работать с родными типами данных и только при передачи данных на клиента будут происходить следующие преобразования

Значение параметра Native type Legacy type

2.5

BOOLEAN

CHAR(5)

2.5 или 3.0

DECFLOAT

DOUBLE PRECISION

2.5 или 3.0

INT128

BIGINT

2.5 или 3.0

TIME WITH TIME ZONE

TIME WITHOUT TIME ZONE

2.5 или 3.0

TIMESTAMP WITH TIME ZONE

TIMESTAMP WITHOUT TIME ZONE

Давайте попробуем установить DataTypeCompatibility = 3.0 и посмотреть на результаты выполнения наших скриптов.

Результаты выполнения первого скрипта index_ts.php:

PHP version: 8.3.11
Result:
array(4) {
  ["TS_TZ"]=>
  string(19) "2024-09-11 16:51:24"
  ["TS"]=>
  string(19) "2024-09-11 16:51:24"
  ["T_TZ"]=>
  string(8) "16:51:24"
  ["T"]=>
  string(8) "16:51:24"
}

Как видите результат не отличается для типов с часовыми поясами и без.

Результаты выполнения второго скрипта index_n.php:

PHP version: 8.3.11
Result:
array(2) {
  ["SUM_SALARY"]=>
  string(11) "16203468.02"
  ["SUM_SALARY_2"]=>
  string(11) "16203468.02"
}

Тут сумма выводится верно.

Результаты выполнения третьего скрипта index_df.php:

PHP version: 8.3.11
Result:
array(2) {
  ["DF"]=>
  string(8) "12354.68"
  ["D"]=>
  string(8) "12354.68"
}

Этот способ решения проблемы хорош тем что наиболее прост для того чтобы заставить ваши старые проекты работать правильно без каких-либо изменений кода, но он имеет существенные недостатки:

  • не всегда имеется возможность редактировать конфигурационные файлы firebird.conf или databases.conf;

  • теряется информация об истинных значениях полей.

Что если завтра вам всё таки потребуется информация о часовом поясе? Что если сумма превышает вместимость NUMERIC(18, x)? Эту проблему можно решить с помощью SQL оператора SET BIND OF.

Давайте уберём DataTypeCompatibility = 3.0 из конфигурационного файла и посмотрим на второй способ решения проблемы.

Использование оператора SET BIND OF

Синтаксис оператора SET BIND OF выглядит следующим образом:

Синтаксис оператора SET BIND OF
SET BIND
  OF {<type-from> | TIME ZONE}
  TO { <type-to> | LEGACY | EXTENDED | NATIVE }
Таблица 1. Параметры оператора SET BIND OF
Параметр Описание

type-from

Тип данных для которого задаётся правило преобразования.

type-to

Тип данных в который следует преобразовать.

Данный оператор позволяет задать правила описания типов возвращаемых клиенту нестандартным способом — тип type-from автоматически преобразуется к типу type-to.

Если используется неполное определение типа (например CHAR вместо CHAR(n)) в левой части SET BIND OF приведения, то преобразование будет осуществляться для всех CHAR столбцов, а не только для CHAR(1).

Специальный неполный тип TIME ZONE обозначает все типы, а именно {TIME | TIMESTAMP} WITH TIME ZONE. Когда неполное определение типа используется в правой части оператора (часть TO), сервер автоматически определит недостающие детали этого типа на основе исходного столбца.

Изменение связывания любого NUMERIC и DECIMAL типа не влияет на соответствующий базовый целочисленный тип. Напротив, изменение привязки целочисленного типа данных также влияет на соответствующие NUMERIC и DECIMAL.

Ключевое слово LEGACY в части TO используется, когда тип данных, отсутствующий в предыдущей версии Firebird, должен быть представлен способом понятным для старого клиентского программного обеспечения (возможна некоторая потеря данных). Существуют следующие преобразования в LEGACY типы:

Таблица 2. Преобразования в legacy типы
Native тип Legacy тип

BOOLEAN

CHAR(5)

DECFLOAT

DOUBLE PRECISION

INT128

BIGINT

TIME WITH TIME ZONE

TIME WITHOUT TIME ZONE

TIMESTAMP WITH TIME ZONE

TIMESTAMP WITHOUT TIME ZONE

Использование EXTENDED в части TO заставляет Firebird использовать расширенную форму типа в части FROM. В настоящее время он работает только для {TIME | TIMESTAMP} WITH TIME ZONE — они принудительно приводятся к EXTENDED {TIME | TIMESTAMP} WITH TIME ZONE.

Установка NATIVE означает, что тип будет использоваться так, как если бы для него не было предыдущих правил преобразования.

Давайте посмотрим применение оператора SET BIND OF на одном из наших примеров. Для начала приведём все новые типы данных к соответствующим LEGACY типам.

Скрипт index_ts_bind_legacy.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const COERCE_SQL = <<<'SQL'
   EXECUTE BLOCK
   AS
   BEGIN
       SET BIND OF TIME ZONE TO LEGACY;
       SET BIND OF INT128 TO LEGACY;
       SET BIND OF DECFLOAT TO LEGACY;
   END
SQL;

const TS_SQL = <<<'SQL'
    SELECT
        CURRENT_TIMESTAMP AS TS_TZ,
        LOCALTIMESTAMP AS TS,
        CURRENT_TIME AS T_TZ,
        LOCALTIME AS T
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $dbh->exec(COERCE_SQL);
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Result:
array(4) {
  ["TS_TZ"]=>
  string(19) "2024-09-11 17:26:33"
  ["TS"]=>
  string(19) "2024-09-11 17:26:33"
  ["T_TZ"]=>
  string(8) "17:26:33"
  ["T"]=>
  string(8) "17:26:33"
}

Как видим результат тот же самый, что и при установке DataTypeCompatibility = 3.0. Для остальных примеров будет тоже самое.

Но оператор SET BIND OF гораздо более мощный. Мы можем любой тип данных преобразовать в любой другой совместимый тип. Поскольку в языке php не существует родных типов данных для представления типов данных Firebird 4.0, то наиболее логично вывести их строковое представление. Давайте попробуем сделать это.

Скрипт index_ts_bind.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const COERCE_SQL = <<<'SQL'
   EXECUTE BLOCK
   AS
   BEGIN
       SET BIND OF TIME ZONE TO VARCHAR;
       SET BIND OF INT128 TO VARCHAR;
       SET BIND OF DECFLOAT TO VARCHAR;
   END
SQL;

const TS_SQL = <<<'SQL'
    SELECT
        CURRENT_TIMESTAMP AS TS_TZ,
        LOCALTIMESTAMP AS TS,
        CURRENT_TIME AS T_TZ,
        LOCALTIME AS T
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $dbh->exec(COERCE_SQL);
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Result:
array(4) {
  ["TS_TZ"]=>
  string(38) "2024-09-11 17:33:23.9400 Europe/Moscow"
  ["TS"]=>
  string(19) "2024-09-11 17:33:23"
  ["T_TZ"]=>
  string(27) "17:33:23.0000 Europe/Moscow"
  ["T"]=>
  string(8) "17:33:23"
}

Отличный результат! Для двух других примеров тоже всё хорошо. Достаточно сразу после соединения выполнить дополнительный запрос для привязки типов данных и вы можете выводить новые типы данных без потерь. Но у этого способа тоже есть недостатки:

  • установку привязки типов данных надо делать при каждом соединении, а это дополнительный запрос к Firebird. Кроме того, если создание вашего соединения с базой данных не централизовано, то придётся менять код вашего приложения в каждом из этих мест.

  • формат вывода даты и времени зависит от текущей локали, поэтому при переносе в другую среду формат вывода может изменится.

Работа с Firebird 4.0 в PHP 8.4

Ну а теперь посмотрим, как работают наши скрипты в PHP 8.4.

Скрипт index_ts.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        CURRENT_TIMESTAMP AS TS_TZ,
        LOCALTIMESTAMP AS TS,
        CURRENT_TIME AS T_TZ,
        LOCALTIME AS T
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Result:
array(4) {
  ["TS_TZ"]=>
  string(33) "2024-09-11 17:44:52 Europe/Moscow"
  ["TS"]=>
  string(19) "2024-09-11 17:44:52"
  ["T_TZ"]=>
  string(22) "17:44:52 Europe/Moscow"
  ["T"]=>
  string(8) "17:44:52"
}

Отлично. Мы ничего не меняли и всё заработало "из коробки".

Теперь посмотрим на пример с суммами.

Скрипт index_n.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        SUM(SALARY) AS SUM_SALARY,
        CAST(SUM(SALARY) AS NUMERIC(18, 2)) AS SUM_SALARY_2
    FROM EMPLOYEE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Result:
array(2) {
  ["SUM_SALARY"]=>
  string(11) "16203468.02"
  ["SUM_SALARY_2"]=>
  string(11) "16203468.02"
}

Тоже хорошо.

И наконец пример с DECFLOAT.

Скрипт index_df.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    SELECT
        QUANTIZE(12354.678, 123.54) AS DF,
        CAST(QUANTIZE(12354.678, 123.54) AS DOUBLE PRECISION) AS D
    FROM RDB$DATABASE
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute();
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    echo "Result: \n";
    var_dump($row);
    echo "\n";
    $sth->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Result:
array(2) {
  ["DF"]=>
  string(8) "12354.68"
  ["D"]=>
  string(8) "12354.68"
}

И здесь всё хорошо.

Таким образом в предстоящий версии PHP 8.4 вы сможете работать со всеми типами данных Firebird 4.0 и Firebird 5.0 без дополнительных "костылей". Рад сообщить вам, что ваш покорный слуга лично приложил свою руку для обеспечения этой возможности. Надеюсь данная статья и описанное нововведение ускорит миграцию на современные версии Firebird, в том числе на самую последнюю версию Firebird 5.0.

Nullable параметры

Работая над поддержкой новых типов данных я вспомнил ещё об одной очень неприятной особенности драйвера pdo_firebird. Сейчас я её продемонстрирую.

Допустим у вас есть таблица, описанная следующим образом:

create sequence gen_employee;

create table employee (
  employee_id bigint not null,
  name varchar(50) not null,
  lastname varchar(50)
);

set term ^;

create trigger tr_employee_bi
for employee before insert
as
begin
  if (new.employee_id is null) then
     new.employee_id = next value for gen_employee;
end^

set term ;^

Теперь попробуем выполнить следующий скрипт.

Скрипт index_nullable.php
<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TS_SQL = <<<'SQL'
    INSERT INTO employee (employee_id, name, lastname)
	VALUES (?, ?, ?)
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/test;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->prepare(TS_SQL);
    $sth->execute([null, 'John', 'Smith']);
    echo "OK\n";

	$cur_stmt = $dbh->prepare('select * from employee');
	$cur_stmt->execute();
	$rows = $cur_stmt->fetchAll(PDO::FETCH_ASSOC);
	var_dump($rows);
    $cur_stmt->closeCursor();
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате получаем:

PHP version: 8.3.11
Error: SQLSTATE[HY105]: Invalid parameter type: -999 Parameter requires non-null value

Всё дело в том, что драйвер опирается на информацию о параметрах, которую он получает в структуре SQLDA, где первый параметр описан как not nullable, поскольку поле EMPLOYEE_ID описано как NOT NULL. Но на самом деле в этот параметр возможно передать значение NULL, потому что существует триггер tr_employee_bi, который изменяет значение столбцов таблицы перед вставкой. Вообще nullable флаг полезен для выходных параметров, поскольку позволяет сэкономить на выделении памяти под индикатор значения NULL. но дл входных параметров такое поведение скорее вредит.

Если мы попробуем выполнить следующий запрос, то он будет успешен

INSERT INTO employee (employee_id, name, lastname)
VALUES (null, 'John', 'Smith')

Поскольку я всё равно начал заниматься драйвером pdo_firebird, то решил исправить и эту проблему. Теперь попробуем выполнить тоже самое на PHP 8.4. Результат:

PHP version: 8.4.0beta5
OK
array(1) {
  [0]=>
  array(3) {
    ["EMPLOYEE_ID"]=>
    int(2)
    ["NAME"]=>
    string(4) "John"
    ["LASTNAME"]=>
    string(5) "Smith"
  }
}

Теперь всё работает как ожидалось.

Режим изолированности транзакций

Как известно, по умолчанию PDO работает в режиме автоматического старта и подтверждения транзакций. В этом случае сразу после соединения с базой данных стартует транзакция по умолчанию. После выполнения любого запроса, транзакция автоматически подтверждается и стартует новая транзакция.

Для ручного управления транзакциями необходимо отключить режим автоматического подтверждения. Это можно сделать при помощи установки аттрибута PDO::ATTR_AUTOCOMMIT в значение false, после чего транзакциями можно управлять при помощи методов beginTransaction, commit и rollback. Но в метод beginTransaction невозможно передать параметры транзакции и изменить режим её изолированности.

Давайте посмотрим с какими параметрами стартует транзакция по умолчанию:

<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TNX_PROP_SQL = <<<'SQL'
    SELECT
      TRIM(
        CASE
          WHEN T.MON$ISOLATION_MODE = 0 THEN 'CONSISTENCY'
          WHEN T.MON$ISOLATION_MODE = 1 THEN 'CONCURRENCY'
          WHEN T.MON$ISOLATION_MODE = 2 THEN 'READ COMMITTED RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 3 THEN 'READ COMMITTED NO RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 4 THEN 'READ COMMITTED READ CONSISTENCY'
        END
      ) AS ISOLATION_MODE,
      TRIM(
        CASE
          WHEN T.MON$LOCK_TIMEOUT = 0 THEN 'NO WAIT'
          ELSE 'WAIT'
        END
      ) AS WAIT_MODE,
      CASE
        WHEN T.MON$LOCK_TIMEOUT > 0 THEN MON$LOCK_TIMEOUT
      END AS LOCK_TIMEOUT,
      TRIM(
        CASE
          WHEN T.MON$READ_ONLY = 1 THEN 'READ ONLY'
          WHEN T.MON$READ_ONLY = 0 THEN 'READ WRITE'
        END
      ) AS RW_MODE,
      (T.MON$AUTO_COMMIT = 1) AS AUTO_COMMIT,
      (T.MON$AUTO_UNDO = 1) AS AUTO_UNDO
    FROM
      MON$TRANSACTIONS T
    WHERE T.MON$TRANSACTION_ID = CURRENT_TRANSACTION
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $sth = $dbh->query(TNX_PROP_SQL);
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    $sth->closeCursor();
    echo "Transaction property: \n";
    var_dump($row);
    echo "\n";
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Transaction property:
array(6) {
  ["ISOLATION_MODE"]=>
  string(31) "READ COMMITTED READ CONSISTENCY"
  ["WAIT_MODE"]=>
  string(4) "WAIT"
  ["LOCK_TIMEOUT"]=>
  NULL
  ["RW_MODE"]=>
  string(9) "READ WRITE"
  ["AUTO_COMMIT"]=>
  bool(false)
  ["AUTO_UNDO"]=>
  bool(true)
}

Для того, чтобы обойти эту проблему стартовать транзакции явно можно с помощью SQL оператора SET TRANSACTION. Давайте посмотрим как это сделать.

<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TNX_PROP_SQL = <<<'SQL'
    SELECT
      TRIM(
        CASE
          WHEN T.MON$ISOLATION_MODE = 0 THEN 'CONSISTENCY'
          WHEN T.MON$ISOLATION_MODE = 1 THEN 'CONCURRENCY'
          WHEN T.MON$ISOLATION_MODE = 2 THEN 'READ COMMITTED RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 3 THEN 'READ COMMITTED NO RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 4 THEN 'READ COMMITTED READ CONSISTENCY'
        END
      ) AS ISOLATION_MODE,
      TRIM(
        CASE
          WHEN T.MON$LOCK_TIMEOUT = 0 THEN 'NO WAIT'
          ELSE 'WAIT'
        END
      ) AS WAIT_MODE,
      CASE
        WHEN T.MON$LOCK_TIMEOUT > 0 THEN MON$LOCK_TIMEOUT
      END AS LOCK_TIMEOUT,
      TRIM(
        CASE
          WHEN T.MON$READ_ONLY = 1 THEN 'READ ONLY'
          WHEN T.MON$READ_ONLY = 0 THEN 'READ WRITE'
        END
      ) AS RW_MODE,
      (T.MON$AUTO_COMMIT = 1) AS AUTO_COMMIT,
      (T.MON$AUTO_UNDO = 1) AS AUTO_UNDO
    FROM
      MON$TRANSACTIONS T
    WHERE T.MON$TRANSACTION_ID = CURRENT_TRANSACTION
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    // start transaction
    $dbh->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
    $dbh->exec('SET TRANSACTION READ WRITE NO WAIT ISOLATION LEVEL SNAPSHOT');
    // execute query
    $sth = $dbh->query(TNX_PROP_SQL);
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    $sth->closeCursor();
    echo "Transaction property: \n";
    var_dump($row);
    echo "\n";
    // commit transaction
    //$dbh->exec('COMMIT');
    $dbh->commit();
    $dbh->setAttribute(PDO::ATTR_AUTOCOMMIT, true);
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.3.11
Transaction property:
array(6) {
  ["ISOLATION_MODE"]=>
  string(11) "CONCURRENCY"
  ["WAIT_MODE"]=>
  string(7) "NO WAIT"
  ["LOCK_TIMEOUT"]=>
  NULL
  ["RW_MODE"]=>
  string(10) "READ WRITE"
  ["AUTO_COMMIT"]=>
  bool(false)
  ["AUTO_UNDO"]=>
  bool(true)
}

Error: There is no active transaction

Нам удалось изменить уровень изолированности транзакции, но с подтверждением такой транзакции возникли проблемы, причём любым из методов.

Мне не нравится такое поведение, и возможно следующее чем я займусь — исправлю это до выхода финальной версии PHP 8.4.

Теперь посмотрим какие возможности нам предоставили разработчики PHP 8.4 для изменения уровня изолированности транзакции через аттрибуты соединения.

В PHP 8.4 для драйверов PDO были добавлены дополнительные классы пространстве имён PDO, которые предоставляют дополнительные аттрибуты и методы для специфичного драйвера. Для драйвера Firebird такой класс называется PDO\Firebird. Он описан следующим образом:

namespace Pdo;

class Firebird extends \PDO
{
    // Attributes for date and time formats
    public const int ATTR_DATE_FORMAT;
    public const int ATTR_TIME_FORMAT;
    public const int ATTR_TIMESTAMP_FORMAT;

    public const int TRANSACTION_ISOLATION_LEVEL;

    // Transaction isolation level
    public const int READ_COMMITTED;
    public const int REPEATABLE_READ;
    public const int SERIALIZABLE;

    public const int WRITABLE_TRANSACTION;

    public static function getApiVersion(): int;
}

Аттрибут PDO\Firebird::WRITABLE_TRANSACTION предназначен для установки режима доступа транзакции READ ONLY или READ WRITE, а аттрибут PDO\Firebird::TRANSACTION_ISOLATION_LEVEL для переключения режима изолированности. Константы режимом изолированности соответствуют следующим параметрам транзакции:

  • PDO\Firebird::READ_COMMITTED - READ COMMITTED RECORD_VERSION. В Firebird 4.0 и выше если параметр конфигурации ReadConsistency = 1, режим изолированности будет READ COMMITTED READ CONSISTENCY;

  • PDO\Firebird::REPEATABLE_READ - SNAPSHOT;

  • PDO\Firebird::SERIALIZABLE - SNAPSHOT TABLE STABILITY.

Давайте посмотрим как их можно использовать.

<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TNX_PROP_SQL = <<<'SQL'
    SELECT
      TRIM(
        CASE
          WHEN T.MON$ISOLATION_MODE = 0 THEN 'CONSISTENCY'
          WHEN T.MON$ISOLATION_MODE = 1 THEN 'CONCURRENCY'
          WHEN T.MON$ISOLATION_MODE = 2 THEN 'READ COMMITTED RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 3 THEN 'READ COMMITTED NO RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 4 THEN 'READ COMMITTED READ CONSISTENCY'
        END
      ) AS ISOLATION_MODE,
      TRIM(
        CASE
          WHEN T.MON$LOCK_TIMEOUT = 0 THEN 'NO WAIT'
          ELSE 'WAIT'
        END
      ) AS WAIT_MODE,
      CASE
        WHEN T.MON$LOCK_TIMEOUT > 0 THEN MON$LOCK_TIMEOUT
      END AS LOCK_TIMEOUT,
      TRIM(
        CASE
          WHEN T.MON$READ_ONLY = 1 THEN 'READ ONLY'
          WHEN T.MON$READ_ONLY = 0 THEN 'READ WRITE'
        END
      ) AS RW_MODE,
      (T.MON$AUTO_COMMIT = 1) AS AUTO_COMMIT,
      (T.MON$AUTO_UNDO = 1) AS AUTO_UNDO
    FROM
      MON$TRANSACTIONS T
    WHERE T.MON$TRANSACTION_ID = CURRENT_TRANSACTION
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO('firebird:dbname=inet://localhost/employee;charset=utf8', 'SYSDBA', 'masterkey');
    $dbh->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
    $dbh->setAttribute(PDO\Firebird::TRANSACTION_ISOLATION_LEVEL, PDO\Firebird::REPEATABLE_READ);
    $dbh->setAttribute(PDO\Firebird::WRITABLE_TRANSACTION, false);
    // start transaction
    $dbh->beginTransaction();
    // execute query
    $sth = $dbh->query(TNX_PROP_SQL);
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    $sth->closeCursor();
    echo "Transaction property: \n";
    var_dump($row);
    echo "\n";
    // commit transaction
    $dbh->commit();
    $dbh->setAttribute(PDO::ATTR_AUTOCOMMIT, true);
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Transaction property:
array(6) {
  ["ISOLATION_MODE"]=>
  string(11) "CONCURRENCY"
  ["WAIT_MODE"]=>
  string(4) "WAIT"
  ["LOCK_TIMEOUT"]=>
  NULL
  ["RW_MODE"]=>
  string(9) "READ ONLY"
  ["AUTO_COMMIT"]=>
  bool(false)
  ["AUTO_UNDO"]=>
  bool(true)
}

Кроме того, эти аттрибуты можно применять прямо при установке соединения и тогда даже транзакция транзакция по умолчанию, которая стартует вместе с соединением изменит свои параметры.

<?php

namespace MyApp;

use PDO;
use PDOException;
use PDOStatement;

const TNX_PROP_SQL = <<<'SQL'
    SELECT
      TRIM(
        CASE
          WHEN T.MON$ISOLATION_MODE = 0 THEN 'CONSISTENCY'
          WHEN T.MON$ISOLATION_MODE = 1 THEN 'CONCURRENCY'
          WHEN T.MON$ISOLATION_MODE = 2 THEN 'READ COMMITTED RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 3 THEN 'READ COMMITTED NO RECORD VERSION'
          WHEN T.MON$ISOLATION_MODE = 4 THEN 'READ COMMITTED READ CONSISTENCY'
        END
      ) AS ISOLATION_MODE,
      TRIM(
        CASE
          WHEN T.MON$LOCK_TIMEOUT = 0 THEN 'NO WAIT'
          ELSE 'WAIT'
        END
      ) AS WAIT_MODE,
      CASE
        WHEN T.MON$LOCK_TIMEOUT > 0 THEN MON$LOCK_TIMEOUT
      END AS LOCK_TIMEOUT,
      TRIM(
        CASE
          WHEN T.MON$READ_ONLY = 1 THEN 'READ ONLY'
          WHEN T.MON$READ_ONLY = 0 THEN 'READ WRITE'
        END
      ) AS RW_MODE,
      (T.MON$AUTO_COMMIT = 1) AS AUTO_COMMIT,
      (T.MON$AUTO_UNDO = 1) AS AUTO_UNDO
    FROM
      MON$TRANSACTIONS T
    WHERE T.MON$TRANSACTION_ID = CURRENT_TRANSACTION
SQL;

try {
    echo 'PHP version: ' . phpversion() . "\n";
    $dbh = new PDO(
        'firebird:dbname=inet://localhost/employee;charset=utf8',
        'SYSDBA',
        'masterkey',
        [
          PDO\Firebird::WRITABLE_TRANSACTION => false
        ]
    );
    $sth = $dbh->query(TNX_PROP_SQL);
    $row = $sth->fetch(PDO::FETCH_ASSOC);
    $sth->closeCursor();
    echo "Transaction property: \n";
    var_dump($row);
    echo "\n";
}
catch(PDOException $e) {
    echo 'Error: ' . $e->getMessage() . "\n";
}

В результате будет выведено следующее:

PHP version: 8.4.0beta5
Transaction property:
array(6) {
  ["ISOLATION_MODE"]=>
  string(31) "READ COMMITTED READ CONSISTENCY"
  ["WAIT_MODE"]=>
  string(4) "WAIT"
  ["LOCK_TIMEOUT"]=>
  NULL
  ["RW_MODE"]=>
  string(9) "READ ONLY"
  ["AUTO_COMMIT"]=>
  bool(false)
  ["AUTO_UNDO"]=>
  bool(true)
}

Теперь транзакция по умолчанию стартует в READ ONLY режиме.

Это всё о чем я хотел рассказал про то как работать с современными версиями Firebird в PHP, и какие улучшения для этого произошли в PHP 8.4.

Подпишитесь на новости Firebird в России

Подписаться