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

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

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

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

Глава 301 · Первое подключение PDO_DBLIB → полезные результаты

PDO_DBLIB: маршрут FreeTDS, поиск по тексту и срок запроса

Полный базис PDO_DBLIB/DSN/Pdo\Dblib и три проекта для Linux PHP: паспорт каталога, Unicode-поиск и ограничение ожидания.

Каталог SQL Server из Linux PHP: маршрут, текст и срок ответа

У вас есть каталог в SQL Server, а приложение PHP работает на Linux. Нужно найти доступный товар, передать сотруднику CSV и получить понятный отказ, если запрос задержался. PDO_DBLIB соединяет PHP с SQL Server или Sybase через библиотеку FreeTDS. Ни наличие класса PDO, ни успешная команда tsql сами по себе не подтверждают весь этот маршрут: у PHP может быть другая сборка клиента и другой файл настроек.

Что проходит через каждый слой
  1. Потребность: «найти кабель категории Монтаж с положительным остатком»
  2. PHP: значение фильтра → PDO prepare/bindValue/execute
  3. PDO_DBLIB → FreeTDS → TDS → таблица SQL Server
  4. Строки sku/title/qty → проверка смысла → search.csv для сотрудника

PDO — общий набор вызовов подключения и чтения; драйвер связывает этот набор с конкретной клиентской библиотекой. TDS — протокол обмена, а SQL Server — сервер, который выполняет SQL. Аналогия с маршрутом доставки полезна для поиска неисправного звена, но не означает, что все драйверы одинаково преобразуют значения или завершают результаты. Эту главу можно читать после таблицы, SELECT и первого PDO-подключения. Ниже сначала показаны сам вызов и результат, затем настройка маршрута и три проекта.

$db = new Pdo\Dblib(
    'dblib:host=127.0.0.1:14179;dbname=phpmanual179;charset=UTF-8;version=7.4',
    $user, $password,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$statement = $db->query('SELECT 3 AS available_qty');
$row = $statement->fetch(PDO::FETCH_ASSOC);
if ($row !== false) {
    echo $row['available_qty']; // 3
}
$statement->closeCursor();
$statement = null;
$db = null;

Это минимальный контекстный фрагмент для уже подготовленной собственной базы: $user и $password прочитаны из закрытой конфигурации. Знак $ начинает имя PHP-переменной, = сохраняет значение, new создаёт соединение. Pdo\Dblib — имя класса с пространством имён; стрелка -> вызывает метод созданного соединения или statement. query возвращает результат запроса, fetch читает одну строку, ключ available_qty совпадает с SQL-псевдонимом AS. В этом простом SELECT получится число 3; fetch возвращает false, когда строки закончились. Проверка !== false не путает конец результата со значением 0 или SQL NULL. closeCursor завершает результат; null снимает PHP-ссылку. Полная схема cleanup разобрана в жизненном цикле операций.

ПроектЧто получит читатель
Паспорт маршрутаroute.json: один каталог через адрес:порт и именованный профиль FreeTDS; реальные версии клиента и TDS.
Поиск доступного кабеляsearch.csv: нужные строки и столбцы, сохранённые кириллица и апостроф, различимые NULL и пустой текст.
Ограничение ожиданияbudget.json: заданный срок, безопасная диагностика отказа и результат нового короткого запроса.

Проекты этой главы рассчитаны на PHP 8.5.11, PDO_DBLIB той же версии, FreeTDS 1.3.17 и отдельный SQL Server 2025 с ProductVersion 17.0.5035.5. Здесь зафиксирован конкретный профиль, а не заявлена совместимость со всеми выпусками SQL Server и Sybase. Сам SQL использует особенности SQL Server, например TOP и UNICODE; смена сервера требует проверки и SQL, и преобразования данных. Результаты и небольшие CSV/JSON остаются после удаления собственных учебных таблиц.

Подготовить PHP с FreeTDS и отдельную учебную базу

Подготовка состоит из двух независимых частей: PHP с PDO_DBLIB/FreeTDS и доступный сервер с отдельной учебной базой и правами. Расширение не содержит SQL Server и не создаёт сервер автоматически. FreeTDS имеет несколько интерфейсов; здесь нужен DB-Library libsybdb, а не ODBC-вариант. Для сборки PHP нужны совместимые заголовки sybdb.h и библиотека libsybdb, компилятор C, make и инструменты конфигурации. Готовый пакет pdo_dblib также допустим, если его версия и ABI соответствуют выбранному PHP.

СредаПуть подготовки
Linux / UnixСобрать PHP с --enable-pdo --with-pdo-dblib либо установить совместимое расширение из доверенного репозитория. Точный синтаксис --with-pdo-dblib[=DIR] задаёт базу установки FreeTDS. Без DIR используются стандартные пути компилятора.
Обособленная установка FreeTDSDIR должен содержать include/sybdb.h или include/freetds/sybdb.h и библиотеку в выбранном каталоге lib. Для multiarch-систем проверьте реальное расположение библиотеки; произвольный DIR не исправляет несовпадающие заголовки и .so.
Windows PHPСовременный PDO_DBLIB не поставляется для Windows. PDO_SQLSRV или PDO_ODBC дают другой поддерживаемый маршрут. Linux PHP внутри WSL остаётся Linux-программой; это не загрузка старого ntwdblib.dll в Windows PHP.
Сервер для Linux-клиентаНужен доступный TDS endpoint SQL Server/Sybase. Имя Windows LocalDB и named pipe из другого драйвера не заменяют TCP-адрес для FreeTDS. Наши проекты используют отдельный SQL Server, а не мост к LocalDB.

Следующие команды — рецепт отдельного CLI PHP на Linux при уже установленных средствах сборки и FreeTDS development-пакете. Они не заменяют системный PHP сайта. На Ubuntu пакет разработчика называется freetds-dev; устанавливать системные зависимости следует обычным административным способом из официального репозитория. В проверенном профиле использован Ubuntu-пакет 1.3.17+ds-2build3. Для другого дистрибутива имя пакета и путь libsybdb могут отличаться.

LAB_ROOT="$PWD/pdo-dblib-lab"
PHP_PREFIX="$LAB_ROOT/php"
mkdir -p "$LAB_ROOT/source"
cd "$LAB_ROOT/source"
curl --fail --location --output php-8.5.11.tar.xz \
  https://www.php.net/distributions/php-8.5.11.tar.xz
printf '%s  %s\n' \
  d9be75c08e8c316f4c8f4194d8fbe1750a15f6a6d9d4e3fe72082abeeb800360 \
  php-8.5.11.tar.xz | sha256sum --check -
tar -xf php-8.5.11.tar.xz
cd php-8.5.11
./configure --prefix="$PHP_PREFIX" \
  --disable-all --enable-cli --disable-cgi --disable-phpdbg \
  --enable-pdo --with-pdo-dblib --without-pear
make -j2
make install
"$PHP_PREFIX/bin/php" -n --ri PDO
"$PHP_PREFIX/bin/php" -n --ri PDO_DBLIB

Последние две команды должны показать поддержку PDO, драйвер dblib и flavour freetds. -n отключает чтение php.ini и подходит этой статической сборке. Для расширения .so нужен именно ваш php.ini с extension=pdo_dblib; если PDO также собрано отдельным модулем, загрузите его раньше. Сравнивайте CLI и PHP веб-сервера отдельно: исполняемый файл, php.ini, пользователя процесса, загруженный модуль. Доступность tsql полезна для проверки сети, но не доказывает, с какой библиотекой связан PHP. В обсуждении об обновлении FreeTDS именно эти две среды расходились. Наш паспорт читает версию через действительное PDO-соединение.

Подготовить именно собственную учебную базу

Следующий SQL выполняет администратор отдельного учебного SQL Server через своё средство администрирования. До выполнения замените текст пароля своим длинным уникальным значением; настоящий пароль не сохраняйте в общем репозитории, URL или командной строке. Имена phpmanual179 должны быть свободны. Команды CREATE при совпадении имён остановятся: существующие объекты нельзя молча считать учебными. GO ниже — разделитель пакетов в sqlcmd/SSMS, а не SQL для PDO::exec.

USE master;
GO
CREATE DATABASE phpmanual179;
GO
CREATE LOGIN phpmanual179 WITH PASSWORD = '<ЗАМЕНИТЕ_СВОИМ_ПАРОЛЕМ>';
GO
USE phpmanual179;
GO
CREATE USER phpmanual179 FOR LOGIN phpmanual179;
ALTER ROLE db_owner ADD MEMBER phpmanual179;
GO

db_owner здесь нужен для создания и удаления собственных учебных таблиц и процедур пакета. Это не образец прав веб-приложения: рабочей учётной записи выдавайте только нужные разрешения. Сервер должен слушать свой loopback TCP 127.0.0.1:14179. Подготовка этой базы/логина — отдельное административное действие; прикладные проекты не создают сервер и не изменяют чужую базу. В конце каждого проекта удаляется только его случайный реестр объектов.

Сохраните следующий JSON как $LAB_ROOT/private-lab.json, подставив тот же собственный пароль в локальном редакторе. Поля schema и private_lab — защита учебных программ от случайного запуска в другом окружении; это не выдача прав на сервере. Адрес, база и имя пользователя фиксированы контрактом ZIP.

{
  "schema": "aen-pdo-dblib179-private-lab",
  "private_lab": true,
  "host": "127.0.0.1",
  "port": 14179,
  "database": "phpmanual179",
  "authentication": "sql_login",
  "user": "phpmanual179",
  "password": "<ЗАМЕНИТЕ_СВОИМ_ПАРОЛЕМ>"
}

Собственный freetds.conf создаётся по образцу следующего раздела. После распаковки полного архива проекта выполните эти команды из той же оболочки, где определены LAB_ROOT и PHP_PREFIX; EXAMPLE_DIR замените своим каталогом распаковки. Секрет остаётся в файле, а не в аргументах PHP.

export PHPMAN_DBLIB_LAB=pdo-dblib179
export PHPMAN_DBLIB_CONFIG="$LAB_ROOT/private-lab.json"
export FREETDSCONF="$LAB_ROOT/freetds.conf"
chmod 600 "$PHPMAN_DBLIB_CONFIG" "$FREETDSCONF"
EXAMPLE_DIR="$LAB_ROOT/example"
cd "$EXAMPLE_DIR"
"$PHP_PREFIX/bin/php" -n example.php

Пароль не включён в ZIP или опубликованные результаты. Проверочный стенд дополнительно изолирует сеть Linux; это не обязательная часть обычной установки. Если вы используете контейнер или сетевое пространство имён, PHP и сервер должны действительно видеть один выбранный loopback. Программы намеренно не перенастраивают сеть и не запускают службу при отказе подключения.

Официальная загрузка PHP и проверка версии · Правила configure PDO_DBLIB 8.5.11 · Подготовка SQL Server на Linux

DSN: прямой адрес, профиль FreeTDS и реквизиты

DSN — строка, по которой PDO выбирает драйвер и маршрут к базе. Имя драйвера стоит до двоеточия, дальше идут пары имя=значение через точку с запятой. Эта строка не является SQL-запросом. Её собирают из доверенной конфигурации; не подставляют параметры формы в host или dbname. Начните с явного адреса, а повторяющуюся настройку вынесите в именованный профиль FreeTDS.

Два способа указать один учебный сервер
  1. dblib:host=127.0.0.1:14179 → конкретный TCP-адрес
  2. dblib:host=PHPManual179 → секция [PHPManual179] freetds.conf → тот же TCP-адрес
  3. dbname=phpmanual179 → выбрать базу; charset=UTF-8 → кодировка клиентского текста
  4. user/password отдельными аргументами → вход → проверка DB_NAME() → каталог
$dsn = 'dblib:host=127.0.0.1:14179;dbname=phpmanual179;'
     . 'charset=UTF-8;appname=WarehouseExport;version=7.4';
$db = new Pdo\Dblib($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_STR_PARAM => PDO::PARAM_STR_NATL,
    Pdo\Dblib::ATTR_CONNECTION_TIMEOUT => 5,
    Pdo\Dblib::ATTR_QUERY_TIMEOUT => 10,
]);

Точка между PHP-строками соединяет их в одну строку. Точки с запятой внутри кавычек разделяют элементы DSN. Номер порта — часть host после двоеточия; отдельного элемента port в парсере этого драйвера нет. Массив четвёртого аргумента содержит настройки PDO, а не элементы DSN. Реквизиты из $user/$password переданы отдельно, поэтому не приходится печатать пароль вместе с маршрутом при диагностике.

Элемент DSNЗначение, отсутствие и граница
Префикс dblib:Современная сборка через FreeTDS. Manual также сохраняет исторические sybase: для сборки с Sybase ct-lib и mssql: для Microsoft DB-Library. Эти примеры описывают происхождение сборок; замена префикса не устанавливает другой драйвер в текущий PHP.
hostАдрес сервера, при необходимости host:port, либо именованный профиль FreeTDS. Если не задан, парсер PHP использует 127.0.0.1. Это адрес внутри окружения самого PHP, а не автоматически хост Windows/контейнера.
dbnameИмя базы. При отсутствии выбор базы остаётся поведением входа/сервера; проект указывает её явно и проверяет DB_NAME(). Выбор базы не выдаёт на неё права.
charsetКодировка клиентских строк. Для примеров UTF-8; при отсутствии участвует настройка FreeTDS. Это не команда изменения collation, типа столбца или кодировки всех файлов приложения.
appnameНазвание приложения для серверной диагностики. Manual указывает варианты PHP Generic DB-lib / PHP freetds; в выбранной FreeTDS-сборке исходный default PHP freetds. Проекты задают имя явно.
secureЭлемент присутствует в Manual, но не используется. secure=true не включает TLS, не проверяет сертификат и не меняет способ аутентификации.
version — дополнение парсера 8.5.11Выбирает версию TDS через библиотеку. Это не версия PHP, FreeTDS или SQL Server. В примерах 7.4; отсутствие оставляет выбор клиентской библиотеке. Неподдержанная строка завершается ошибкой подключения HY000.
user / password — дополнение парсера 8.5.11Реквизиты из DSN используются, только когда соответствующий аргумент конструктора равен null. Явно переданная пустая строка не равна null и не означает «возьми из DSN». Для читаемого и безопасного журнала лучше передавать закрытые реквизиты отдельно.

PHP Manual кратко перечисляет пять элементов после префикса; version, user и password выше отдельно помечены по исходнику 8.5.11. Тот парсер знает строки 4.2, 4.6, 5.0, 6.0, 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 10.0 и auto; часть вариантов зависит от заголовков FreeTDS при сборке. Этот исторический список не является рекомендацией перебрать версии до успеха. В частности, строка version=8.0 в данном PHP-исходнике сопоставлена со старым DBVERSION_72, а не с современным TDS 8.0/TLS. Для Sybase в таблице соответствий есть исторические ограничения; поведение выбранного SQL Server 7.4-профиля не доказывает поддержку всех этих комбинаций. Новую связку выбирают по документации сервера и фактической клиентской библиотеки.

Именованный маршрут без адреса в каждом скрипте

[PHPManual179]
host = 127.0.0.1
port = 14179
tds version = 7.4
client charset = UTF-8

Это содержимое собственного freetds.conf. Переменная окружения FREETDSCONF должна указывать его абсолютный путь до запуска PHP. Такой файл описывает сервер, а не пароль. Запись [PHPManual179] — локальное имя профиля; для имени сервера/порта существуют отдельные поля. Для именованного экземпляра FreeTDS допускает instance вместо port, тогда требуется обнаружение через UDP 1434; port и instance одновременно не задают. Профиль не требуется копировать в системный /etc или в домашние каталоги всех пользователей.

$db = new Pdo\Dblib(
    'dblib:host=PHPManual179;dbname=phpmanual179;charset=UTF-8;version=7.4',
    $user, $password,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

На реальном сервере подтвердите не только открытие соединения, но и нужную базу и ограниченную выборку. Два маршрута к каталогу сравнивают именно данные. Название профиля выбирается из разрешённого списка приложения; пользовательская строка не должна превращаться в произвольный адрес подключения. FILEDSN из ODBC, sqlsrv:Server= и параметры CharacterSet драйвера SQLSRV сюда не переносятся.

Шифрование относится к FreeTDS, а не к secure в DSN

Для удалённого сервера согласуйте режим encryption, доверенный CA и проверку имени сертификата в конфигурации FreeTDS вашей версии. Например, require запрещает нешифрованное соединение, но одной этой настройки недостаточно для проверки подлинности сервера: отдельно нужен доверенный сертификат/CA и проверка имени. Актуальная документация описывает ca file и check certificate hostname; не отключайте проверку имени как способ исправить несовпадающий адрес. Новые режимы вроде strict имеют версионные требования и не выводятся из названия version=8.0 в старом парсере. Наш отдельный loopback-стенд подтверждает учебные запросы, а не TLS-развёртывание удалённой системы.

PHP Manual: состав DSN · Парсер PHP 8.5.11 · FreeTDS: профиль, маршрутизация и шифрование

Pdo\Dblib: семь настроек, строковые параметры и версии

Pdo\Dblib появился в PHP 8.4.0 как специализированный подкласс PDO. Он представляет одно подключение и наследует обычные методы PDO; отдельного нового набора SQL-методов у него нет. Его семь собственных констант позволяют назвать настройки FreeTDS понятным именем. Наличие у родительского PDO всех общих констант не обещает поддержку каждого атрибута этим драйвером.

$db = new Pdo\Dblib($dsn, $user, $password, $options);
// Альтернатива с PHP 8.4: фабрика выбирает поддерживаемый подкласс по DSN.
$db = PDO::connect($dsn, $user, $password, $options);

Это два альтернативных способа создания, а не две обязательные строки подряд. Сигнатура конструктора принимает string $dsn, ?string $username=null, чувствительный ?string $password=null и ?array $options=null; успешное new возвращает объект подключения, отказ входа вызывает PDOException. PDO::connect имеет те же входы и возвращает соответствующий PDO-подкласс. В программах выбирают один способ; наличие объекта ещё не заменяет проверку разрешённого запроса. Общие конструктор PDO и PDO::connect() объясняют остальные правила вызова.

Константа Pdo\DblibНазначение и точная граница 8.5.11
ATTR_CONNECTION_TIMEOUTЦелое число секунд ожидания подключения/login. Передаётся в options конструктора до входа. Отдельный setter после подключения и getter этого значения в рассматриваемом драйвере не реализованы.
ATTR_QUERY_TIMEOUTЦелое число секунд ожидания ответа на запрос. Задаётся при создании или setAttribute. Работает через глобальный для DB-Library dbsettime; не считайте его независимым таймером одного объекта. Getter значения не реализован: храните выбранный бюджет в конфигурации.
ATTR_STRINGIFY_UNIQUEIDENTIFIERbool, исходно false. true выбирает текстовое представление SQL uniqueidentifier; false оставляет представление драйвера. Это отдельная настройка от общего STRINGIFY_FETCHES. Проект сопоставления документов показывает фактические 16 байт и читаемый UUID.
ATTR_VERSIONТолько чтение. Возвращает строку версии подключённой библиотеки DB-Library, например freetds v1.3.17. Не версия сервера и не phpversion расширения.
ATTR_TDS_VERSIONТолько чтение. Возвращает распознанную версию протокола этого соединения как строку; исходник допускает false для неизвестного значения. В нашем профиле строка 7.4.
ATTR_SKIP_EMPTY_ROWSETSbool, исходно false. true пропускает результаты без столбцов. Это не команда удалить пустой SELECT, у которого столбцы есть, но строк нет. Связано с обходом nextRowset и подтверждением операции.
ATTR_DATETIME_CONVERTbool, по умолчанию false. false использует обычное представление даты драйвера; true включает преобразование через пользовательский/локальный формат FreeTDS locales.conf. Не обещает DateTime-объект или автоматический перевод часового пояса. Ведомость с точными датами задаёт формат выгрузки отдельно.

setAttribute возвращает bool, getAttribute — значение соответствующего типа. Неподдержанная возможность при ERRMODE_EXCEPTION может завершиться исключением IM001. В этой сборке общие PDO::ATTR_CLIENT_VERSION и PDO::ATTR_SERVER_VERSION не заменяют ATTR_VERSION: они не реализованы. Серверную версию можно отдельно запросить через SQL SERVERPROPERTY; имя сервера и полный текст технической ошибки в публичный отчёт переносить не нужно.

Настройка строк: общий PDO-атрибут, важный именно здесь

$db->setAttribute(PDO::ATTR_DEFAULT_STR_PARAM, PDO::PARAM_STR_NATL);
$q = $db->prepare('SELECT sku FROM inventory WHERE title = ?');
$q->bindValue(1, "Кабель O'Reilly", PDO::PARAM_STR | PDO::PARAM_STR_NATL);
$q->execute();

PDO::ATTR_DEFAULT_STR_PARAM принимает PARAM_STR_CHAR или PARAM_STR_NATL. Исходное поведение PDO_DBLIB 8.5.11 — CHAR, без N перед SQL-строкой; учебный helper явно выбирает NATL. При эмуляции prepare NATL создаёт национальный SQL-литерал N'…', а апостроф внутри значения удваивается драйвером. Явный флаг PARAM_STR_CHAR у конкретной ASCII-величины переопределяет национальный default. Эти флаги поддерживаются PDO с PHP 7.2; тип столбца NVARCHAR и charset=UTF-8 решают другие части задачи и тоже должны быть согласованы. Проверить применение можно в поиске кабеля.

PDO_DBLIB использует только эмулируемые параметры; getAttribute(PDO::ATTR_EMULATE_PREPARES) сообщает true. setAttribute(..., false) не создаёт отсутствующий серверный механизм подготовки. Значения передают через bindValue/execute, структуру SQL выбирают из собственного кода. Не переносите SQLSRV_ATTR_ENCODING, SQLSRV_ATTR_DIRECT_QUERY или ODBC_ATTR_ASSUME_UTF8: это настройки других драйверов.

Переход со старых имён констант

Устаревший с PHP 8.5 псевдонимИмя для PHP 8.4+
PDO::DBLIB_ATTR_CONNECTION_TIMEOUTPdo\Dblib::ATTR_CONNECTION_TIMEOUT
PDO::DBLIB_ATTR_QUERY_TIMEOUTPdo\Dblib::ATTR_QUERY_TIMEOUT
PDO::DBLIB_ATTR_STRINGIFY_UNIQUEIDENTIFIERPdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER
PDO::DBLIB_ATTR_VERSIONPdo\Dblib::ATTR_VERSION
PDO::DBLIB_ATTR_TDS_VERSIONPdo\Dblib::ATTR_TDS_VERSION
PDO::DBLIB_ATTR_SKIP_EMPTY_ROWSETSPdo\Dblib::ATTR_SKIP_EMPTY_ROWSETS
PDO::DBLIB_ATTR_DATETIME_CONVERTPdo\Dblib::ATTR_DATETIME_CONVERT

Это замена имени константы, а не переключение на другой сетевой клиент. Для кода, который обязан поддерживать PHP до 8.4, стратегию выбора совместимого имени отделяют от учебных примеров новой версии. Общие PDO::ATTR_ERRMODE, ATTR_DEFAULT_FETCH_MODE, ATTR_STRINGIFY_FETCHES и ATTR_DEFAULT_STR_PARAM остаются общими и не получают префикс DBLIB. Семь старых имён доступны лишь при загруженном расширении; новое имя тоже не устанавливает отсутствующий модуль.

Унаследованная группаКуда перейти за подробностями
__construct, connect, getAvailableDrivers, getAttribute, setAttributeСоздание и настройки: справочник PDO.
prepare, query, exec, quoteКоманда и значения: начало PDO; фактическое преобразование типов и результатов DBLIB.
errorCode, errorInfoДиагностика соединения; у statement есть собственная диагностика. PDO::errorInfo() объясняет массив состояния.
beginTransaction, inTransaction, commit, rollBack, lastInsertIdГраницы операции и получение своего номера: следующая прикладная глава. Наследование метода lastInsertId не доказывает безопасность @@IDENTITY при триггерах.

PHP Manual: Pdo\Dblib · PHP 8.5: замена констант · Исходные атрибуты и их default

Сколько ждать: вход, SQL и общий таймер DB-Library

Сервису нужны два разных ограничения: сколько ждать входа на сервер и сколько ждать ответ на SQL. PDO_DBLIB называет их CONNECTION_TIMEOUT и QUERY_TIMEOUT. Единица — секунда. Это не миллисекунды SQL Server LOCK_TIMEOUT и не max_execution_time PHP. Для рабочего приложения храните выбранные значения явно и согласуйте их с общим сроком HTTP-запроса или фоновой задачи.

$options = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    Pdo\Dblib::ATTR_CONNECTION_TIMEOUT => 5,
    Pdo\Dblib::ATTR_QUERY_TIMEOUT => 10,
];
$db = new Pdo\Dblib($dsn, $user, $password, $options);
$db->setAttribute(Pdo\Dblib::ATTR_QUERY_TIMEOUT, 1);
// Здесь выполняется отдельный короткий запрос с выбранным бюджетом.
$db->setAttribute(Pdo\Dblib::ATTR_QUERY_TIMEOUT, 10);

В конструкторе 8.5.11 отсутствующее специальное значение берётся из общего PDO::ATTR_TIMEOUT; в ветке обработки options его fallback равен 30. В уже созданном соединении setAttribute(PDO::ATTR_TIMEOUT, …) направляется в тот же dbsettime, что QUERY_TIMEOUT. Поэтому одно имя ATTR_TIMEOUT в чужом примере не говорит, ограничен ли вход или следующий запрос. Без явно заданной политики нельзя считать все окружения одинаковыми; 0 в клиентском API означает отсутствие такого ограничения, а отрицательные и чрезмерные значения не должны становиться конфигурацией сервиса.

Один процесс и общий клиентский таймер
  1. PHP-процесс: соединение A и соединение B используют одну DB-Library
  2. setAttribute на A → dbsettime → меняется общий срок ожидания запросов
  3. Следующая операция B тоже может попасть под эту политику
  4. Выбранный срок хранится в настройке приложения; независимые задачи при необходимости разделяют по процессам

В FreeTDS 1.3.17 dbsettime меняет контекст библиотеки и существующие соединения, использующие общий срок. Это подтверждено исходником; нельзя продавать два PDO-объекта как два независимых бюджета. Открытие нового PDO с options также устанавливает клиентские сроки. Во всех учебных проектах один CLI-процесс завершает свои операции последовательно. Если нужно несколько параллельных независимых политик, архитектура приложения должна учитывать эту глобальность.

В обсуждении зависающего запроса через VPN вопрос был о предельном ожидании, а не об ускорении самого SQL. Ответ для нынешнего драйвера состоит из явной настройки, перехвата отказа и определённого дальнейшего действия. Наш проект использует контролируемую задержку только для демонстрации этой границы; он не воспроизводит обрыв VPN, не останавливает сервер и не переносит прежние предположения о SQLSTATE из другого PDO-драйвера.

Тайм-аут не даёт универсального ответа, успел ли изменяющий запрос зафиксировать данные. Автоматический повтор INSERT без бизнес-ключа может создать дубль. В проекте задерживается чтение, затем результаты освобождаются и выполняется новый ограниченный SELECT. Для изменений нужен протокол целой операции из главы транзакций и восстановления. Полное сообщение исключения может содержать развёрнутый SQL: в публичный budget.json попадают только заранее выбранные безопасные поля.

FreeTDS: dbsettime и dbsetlogintime · FreeTDS 1.3.17: область действия таймера

Паспорт маршрута: один каталог через адрес и профиль FreeTDS

При переносе каталога на Linux удобно назвать подключение warehouse-demo, а адрес хранить в настройке. Паспорт route.json подтверждает, что прямой адрес и профиль FreeTDS читают одни и те же два доступных товара. В нём есть версии клиента и TDS, но нет пароля или полного DSN. Совпадение названия профиля само по себе ещё не доказывает совпадение базы.

Путь данных и полученный результат
  1. Собственная таблица: Кабель 7, Клемма 0, Лента 4, Короб 2
  2. Адрес:порт → SELECT категории Монтаж, qty > 0 → CAB и TAP
  3. Профиль PHPManual179 → та же база и та же таблица → CAB и TAP
  4. Две выборки + версии клиента/TDS → route.json

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

$db = new Pdo\Dblib(
    'dblib:host=PHPManual179;dbname=phpmanual179;charset=UTF-8;version=7.4',
    $user, $password,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo $db->getAttribute(Pdo\Dblib::ATTR_TDS_VERSION); // 7.4 в этом стенде

new создаёт подключение. Строка после dblib: выбирает адрес и базу; PHPManual179 здесь имя секции freetds.conf. $user и $password — заранее прочитанные закрытые реквизиты. getAttribute читает свойство готового соединения. Полный проект сначала создаёт собственную таблицу, затем сравнивает два маршрута: WHERE выбирает категорию и положительный остаток, TOP (2) ограничивает число строк, ORDER BY id задаёт повторяемый порядок. Количество преобразуется в int на границе прикладного JSON, исходные PHP-типы остаются в наблюдениях.

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

НаблюдениеРезультат
Маршрутыадрес:порт → профиль FreeTDS
Один каталогда
Кабель7
Лента4
Клиентfreetds v1.3.17
Протокол TDS7.4
Файлroute.json
Самостоятельный проект, исходник и архив
Паспорт маршрута: один каталог через адрес и профиль FreeTDSСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p179Config();$db=p179Connect($config);$runtime=p179Runtime($db);

function saveJson179(string $name,array $value): void {
    $text=json_encode($value,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
    if (file_put_contents(__DIR__.'/'.$name,$text)!==strlen($text)) {
        throw new RuntimeException('Не удалось записать собственный JSON.');
    }
}
function saveCsv179(string $name,array $rows): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('Не удалось открыть собственный CSV.');}
    try {foreach($rows as $row) {
        if (fputcsv($file,$row,',','"','',"\n")===false) {
            throw new RuntimeException('Не удалось записать CSV.');
        }
    }} finally {fclose($file);}
}

    $name=p179OwnTable($db,$owned,'route','(id int NOT NULL PRIMARY KEY,sku varchar(12) NOT NULL,
        title nvarchar(80) NOT NULL,category nvarchar(30) NOT NULL,qty int NOT NULL)');
    $table=p179Qualified($name);
    foreach ([[1,'CAB','Кабель','Монтаж',7],[2,'CLP','Клемма','Монтаж',0],
              [3,'TAP','Лента','Монтаж',4],[4,'BOX','Короб','Тара',2]] as $item) {
        p179Command($db,'INSERT INTO '.$table.' (id,sku,title,category,qty) VALUES (?,?,?,?,?)',$item);
    }
    $sql='SELECT TOP (2) sku,title,qty FROM '.$table.' WHERE category=? AND qty>0 ORDER BY id';
    $direct=p179Read($db,$sql,['Монтаж']);
    $directApp=p179Read($db,'SELECT APP_NAME() AS application_name')[0]['application_name'];
    $aliasDb=null;$aliasRows=[];$aliasIdentity=[];$aliasClass=null;
    try {
        $aliasDb=p179Connect($config,['host'=>'PHPManual179','appname'=>'PHPManual179-route']);
        p179AssertDatabase($aliasDb);
        $aliasClass=get_class($aliasDb);
        $aliasIdentity=p179Read($aliasDb,'SELECT DB_NAME() AS database_name,APP_NAME() AS application_name')[0];
        $aliasRows=p179Read($aliasDb,$sql,['Монтаж']);
    } finally {p179Close($aliasDb);}
    $normalize=static fn(array $list):array=>array_map(static fn(array $r):array=>[
        'sku'=>$r['sku'],'title'=>$r['title'],'qty'=>(int)$r['qty']],$list);
    $items=$normalize($direct);$other=$normalize($aliasRows);
    $client=$db->getAttribute(Pdo\Dblib::ATTR_VERSION);
    $tds=$db->getAttribute(Pdo\Dblib::ATTR_TDS_VERSION);
    $passport=['profile'=>'warehouse-demo','driver'=>$db->getAttribute(PDO::ATTR_DRIVER_NAME),
        'connection_class'=>get_class($db),'client'=>$client,'tds'=>$tds,
        'routes'=>['host-port','freetds-alias'],'same_catalog'=>$items===$other,'items'=>$items];
    saveJson179('route.json',$passport);
    $checks=[
        'driver_is_dblib'=>$passport['driver']==='dblib',
        'subclass_both_routes'=>get_class($db)===Pdo\Dblib::class && $aliasClass===Pdo\Dblib::class,
        'alias_own_database'=>$aliasIdentity['database_name']===$config['database'],
        'application_names'=>$directApp==='PHPManual179' && $aliasIdentity['application_name']==='PHPManual179-route',
        'both_routes_same_rows'=>$items===$other,
        'bounded_two_rows'=>count($items)===2,
        'selected_catalog'=>$items===[['sku'=>'CAB','title'=>'Кабель','qty'=>7],['sku'=>'TAP','title'=>'Лента','qty'=>4]],
        'client_identity'=>$client==='freetds v1.3.17',
        'tds_identity'=>$tds==='7.4',
        'alias_connection_released'=>$aliasDb===null,
        'artifact_matches'=>json_decode(file_get_contents(__DIR__.'/route.json'),true,512,JSON_THROW_ON_ERROR)===$passport];
    $rows=[['Маршруты','адрес:порт → профиль FreeTDS'],['Один каталог',$items===$other],
        ['Кабель',7],['Лента',4],['Клиент',$client],['Протокол TDS',$tds],['Файл','route.json']];
    $observations=['passport'=>$passport,'direct_rows'=>$direct,'alias_rows'=>$aliasRows,
        'quantity_types'=>array_map(static fn(array $r):string=>get_debug_type($r['qty']),$direct),
        'application_names'=>[$directApp,$aliasIdentity['application_name']]];

} finally {

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

p179Report('pdo179-route-passport','Паспорт маршрута: один каталог через адрес и профиль FreeTDS',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Маршруты — адрес:порт → профиль FreeTDS
Один каталог — да
Кабель — 7
Лента — 4
Клиент — freetds v1.3.17
Протокол TDS — 7.4
Файл — route.json

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

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

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

Паспорт подтверждает только эти маршруты, права и запрос в данном окружении. Он не проверяет все возможности сервера и не доказывает, что два разных приложения используют один php.ini. Версия FreeTDS, версия TDS и версия SQL Server — три разных величины. APP_NAME помогает найти подключение в диагностике; это название приложения, а не удостоверение его личности. Сравнивать маршруты в рабочей системе следует на согласованной неизменной выборке: параллельное изменение товаров может объяснять различие строк.

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

Добавьте в таблицу ещё один товар категории Монтаж с остатком 6. Должен ли он появиться в паспорте при TOP (2) и ORDER BY id?

Разбор

При большем id новый товар не попадёт в первые две строки. Чтобы получить три строки, измените TOP (2) на TOP (3) в единственной переменной $sql: оба маршрута обязаны использовать одинаковое условие. Если нужен иной приоритет отбора, задайте его в ORDER BY и добавьте уникальный id как последний критерий.

Сотруднику нужен список кабеля, который можно выдать со склада. Начните с PHP-массива условия в коротком фрагменте. В полном проекте catalog.json — приложенный к ZIP обменный файл поставщика с четырьмя товарами; json_decode и формат JSON объясняют превращение такого файла в PHP-массив. search.csv содержит только два доступных товара с остатками 4 и 3. Апостроф в названии не разрывает SQL, а кириллица действительно сохраняется в NVARCHAR. Колонка note_state сохраняет различие SQL NULL, пустого текста и текста «0».

Путь данных и полученный результат
  1. catalog.json: CAB-A 3, CAB-B 0, CAB-C 4, BOX-A 9
  2. UTF-8 + NVARCHAR + NATL → четыре сохранённые строки → commit
  3. category=Монтаж, title LIKE Кабель%, qty>0 → CAB-C 4 и CAB-A 3
  4. sku/title/qty/note_state/note → search.csv

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

$filter = ['title_prefix' => 'Кабель%'];
$q = $db->prepare('SELECT sku,title FROM inventory WHERE title LIKE ?');
$q->bindValue(1, $filter['title_prefix'], PDO::PARAM_STR | PDO::PARAM_STR_NATL);
$q->execute();
while (($row = $q->fetch(PDO::FETCH_ASSOC)) !== false) {
    echo $row['sku'], ' — ', $row['title'], "\n";
}
$q->closeCursor();

Знак ? обозначает значение поиска, а не фрагмент SQL. bindValue связывает первый маркер со строкой: PDO::PARAM_STR говорит о PHP-строке, добавка PARAM_STR_NATL просит национальный SQL-литерал. Символ | объединяет эти совместимые признаки. Процент в LIKE означает любое продолжение, поэтому «Кабель%» выбирает начало названия. В полном проекте INSERT также использует типизированную привязку, затем commit завершает импорт; поиск выполняет ограниченный SELECT с порядком по qty и sku. Массив результатов используется для CSV, а не для фильтрации всей таблицы в PHP.

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

НаблюдениеРезультат
Сохранено товаров4
Найдено в наличии2
Первый товарКабель гибкий
Второй товарКабель O'Reilly
Общий остаток7
NULL и пустая строка различеныда
Файлsearch.csv
Самостоятельный проект, исходник и архив
Поиск доступного кабеля: UTF-8, апостроф и национальные параметрыСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p179Config();$db=p179Connect($config);$runtime=p179Runtime($db);

function saveJson179(string $name,array $value): void {
    $text=json_encode($value,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
    if (file_put_contents(__DIR__.'/'.$name,$text)!==strlen($text)) {
        throw new RuntimeException('Не удалось записать собственный JSON.');
    }
}
function saveCsv179(string $name,array $rows): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('Не удалось открыть собственный CSV.');}
    try {foreach($rows as $row) {
        if (fputcsv($file,$row,',','"','',"\n")===false) {
            throw new RuntimeException('Не удалось записать CSV.');
        }
    }} finally {fclose($file);}
}

    $name=p179OwnTable($db,$owned,'unicode','(id int NOT NULL PRIMARY KEY,sku varchar(12) UNIQUE NOT NULL,
        title nvarchar(80) NOT NULL,category nvarchar(30) NOT NULL,qty int NOT NULL,note nvarchar(80) NULL)');
    $table=p179Qualified($name);
    $input=json_decode(file_get_contents(__DIR__.'/catalog.json'),true,512,JSON_THROW_ON_ERROR);
    if (!is_array($input) || count($input)!==4) {throw new RuntimeException('Неверный учебный каталог.');}
    $insert=null;$search=null;
    try {
        p179Begin($db);
        $insert=$db->prepare('INSERT INTO '.$table.' (id,sku,title,category,qty,note) VALUES (?,?,?,?,?,?)');
        foreach ($input as $item) {
            $insert->bindValue(1,$item['id'],PDO::PARAM_INT);
            $insert->bindValue(2,$item['sku'],PDO::PARAM_STR|PDO::PARAM_STR_CHAR);
            $insert->bindValue(3,$item['title'],PDO::PARAM_STR|PDO::PARAM_STR_NATL);
            $insert->bindValue(4,$item['category'],PDO::PARAM_STR|PDO::PARAM_STR_NATL);
            $insert->bindValue(5,$item['qty'],PDO::PARAM_INT);
            $insert->bindValue(6,$item['note'],$item['note']===null?PDO::PARAM_NULL:PDO::PARAM_STR|PDO::PARAM_STR_NATL);
            $insert->execute();$insert->closeCursor();
        }
        p179Release($insert);p179Commit($db);
        $search=$db->prepare('SELECT TOP (2) sku,title,qty,note FROM '.$table.
            ' WHERE category=? AND title LIKE ? AND qty>0 ORDER BY qty DESC,sku');
        $search->bindValue(1,'Монтаж',PDO::PARAM_STR|PDO::PARAM_STR_NATL);
        $search->bindValue(2,'Кабель%',PDO::PARAM_STR|PDO::PARAM_STR_NATL);
        $search->execute();$raw=p179Rows($search);p179Release($search);
    } finally {p179Release($search);p179Release($insert);p179Rollback($db);}
    $stored=p179Read($db,'SELECT id,sku,title,qty,note,UNICODE(title) AS first_codepoint FROM '.$table.' ORDER BY id');
    $selected=array_map(static fn(array $r):array=>['sku'=>$r['sku'],'title'=>$r['title'],'qty'=>(int)$r['qty'],'note'=>$r['note']],$raw);
    $records=[['sku','title','qty','note_state','note']];
    foreach($selected as $item) {$records[]=[$item['sku'],$item['title'],$item['qty'],
        $item['note']===null?'NULL':($item['note']===''?'EMPTY':'TEXT'),$item['note']??''];}
    saveCsv179('search.csv',$records);
    $checks=[
        'all_four_stored'=>count($stored)===4,
        'utf8_apostrophe_exact'=>$stored[0]['title']==="Кабель O'Reilly",
        'server_unicode_codepoint'=>(int)$stored[0]['first_codepoint']===1050,
        'zero_preserved'=>(int)$stored[1]['qty']===0,
        'null_empty_text_separate'=>$stored[0]['note']===null && $stored[1]['note']==='' && $stored[2]['note']==='0',
        'filtered_order_exact'=>$selected===[['sku'=>'CAB-C','title'=>'Кабель гибкий','qty'=>4,'note'=>'0'],
            ['sku'=>'CAB-A','title'=>"Кабель O'Reilly",'qty'=>3,'note'=>null]],
        'positive_stock_only'=>array_sum(array_column($selected,'qty'))===7,
        'national_default_explicit'=>$db->getAttribute(PDO::ATTR_DEFAULT_STR_PARAM)===PDO::PARAM_STR_NATL,
        'emulation_reported'=>$db->getAttribute(PDO::ATTR_EMULATE_PREPARES)===true,
        'transaction_finished'=>!$db->inTransaction(),
        'artifact_written'=>is_file(__DIR__.'/search.csv') && filesize(__DIR__.'/search.csv')>0];
    $rows=[['Сохранено товаров',count($stored)],['Найдено в наличии',count($selected)],
        ['Первый товар',$selected[0]['title']],['Второй товар',$selected[1]['title']],
        ['Общий остаток',array_sum(array_column($selected,'qty'))],['NULL и пустая строка различены',$checks['null_empty_text_separate']],['Файл','search.csv']];
    $observations=['input'=>$input,'stored_rows'=>$stored,'selected'=>$selected,'csv_records'=>$records,
        'native_quantity_types'=>array_map(static fn(array $r):string=>get_debug_type($r['qty']),$raw),
        'emulate_prepares'=>$db->getAttribute(PDO::ATTR_EMULATE_PREPARES)];

} finally {

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

p179Report('pdo179-unicode-search','Поиск доступного кабеля: UTF-8, апостроф и национальные параметры',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Сохранено товаров — 4
Найдено в наличии — 2
Первый товар — Кабель гибкий
Второй товар — Кабель O'Reilly
Общий остаток — 7
NULL и пустая строка различены — да
Файл — search.csv

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

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

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

PDO_DBLIB 8.5.11 подставляет экранированные значения на стороне PDO: это эмуляция prepare, а не серверный подготовленный запрос. Явная привязка по-прежнему отделяет значение от структуры SQL. PARAM_STR_NATL не выбирает кодировку PHP-файла и не заменяет charset=UTF-8; обычный исходный default драйвера — PARAM_STR_CHAR, наш профиль меняет его явно. LIKE трактует % и _ как шаблон даже при безопасной привязке: для поиска буквального процента нужна отдельная политика экранирования шаблона. Код не превращает имена таблиц, ORDER BY или DSN в параметры.

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

Исключите категорию Монтаж и найдите один товар ровно по названию «Кабель O'Reilly». Нужно ли вручную удваивать апостроф?

Разбор

Замените WHERE на title=? и привяжите полную строку с PARAM_STR_NATL. Для значения с апострофом дополнительное ручное экранирование не требуется: его выполнит PDO. Не заключайте ? в SQL-кавычки. Условие qty>0 можно сохранить, если выдавать можно только доступный товар.

Не ждать медленный отчёт: срок запроса и новый сеанс для каталога

Если отчёт задержался, сотруднику нужен определённый ответ, а не бесконечное ожидание. Здесь выбран срок SQL 1 секунда; контролируемая задержка 2 секунды вызывает отказ. Затем программа открывает новое подключение и получает короткий список остатков 6 и 2. budget.json описывает итог и восстановление, а фактическое время остаётся в result.json. Сервер продолжает работать; данные при задержке не изменяются.

Путь данных и полученный результат
  1. Собственная таблица: CAB 6, CLP 2; изменения уже завершены
  2. QUERY_TIMEOUT=1 → WAITFOR 2 секунды → HY000/20003
  3. closeCursor → закрыть PDO → новый PDO с бюджетом 10 секунд
  4. Короткий SELECT → два товара, общий остаток 8 → budget.json

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

$db->setAttribute(Pdo\Dblib::ATTR_QUERY_TIMEOUT, 1);
try {
    $q = $db->prepare("WAITFOR DELAY '00:00:02'; SELECT 1 AS ready");
    $q->execute();
} catch (PDOException $e) {
    $state = $e->errorInfo[0];
    $driverCode = $e->errorInfo[1];
    // В этом профиле: HY000 и 20003. Полный текст ошибки не публикуем.
}

setAttribute задаёт целое число секунд для клиентского ожидания. WAITFOR — только контролируемая имитация долгого чтения в учебной базе. execute отправляет команду; catch перехватывает PDOException, errorInfo[0] содержит SQLSTATE, [1] — код DB-Library. Малый фрагмент показывает отказ; самостоятельный проект дополнительно освобождает statement в finally, закрывает прежнее соединение и создаёт новое с обычным бюджетом 10 секунд, после чего получает каталог. В PHP-счётчик hrtime попадает фактическое время, а не обещанная точность таймера до миллисекунды.

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

НаблюдениеРезультат
Бюджет SQL, секунд1
Медленный отчёт отклонёнда
ДиагностикаHY000/20003
Восстановлениеновое соединение
Доступных товаров2
Общий остаток8
Файлbudget.json
Самостоятельный проект, исходник и архив
Не ждать медленный отчёт: срок запроса и новый сеанс для каталогаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p179Config();$db=p179Connect($config);$runtime=p179Runtime($db);

function saveJson179(string $name,array $value): void {
    $text=json_encode($value,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
    if (file_put_contents(__DIR__.'/'.$name,$text)!==strlen($text)) {
        throw new RuntimeException('Не удалось записать собственный JSON.');
    }
}
function saveCsv179(string $name,array $rows): void {
    $file=fopen(__DIR__.'/'.$name,'wb');
    if ($file===false) {throw new RuntimeException('Не удалось открыть собственный CSV.');}
    try {foreach($rows as $row) {
        if (fputcsv($file,$row,',','"','',"\n")===false) {
            throw new RuntimeException('Не удалось записать CSV.');
        }
    }} finally {fclose($file);}
}

    $name=p179OwnTable($db,$owned,'budget','(id int NOT NULL PRIMARY KEY,sku varchar(12) NOT NULL,
        title nvarchar(80) NOT NULL,qty int NOT NULL)');
    $table=p179Qualified($name);
    foreach ([[1,'CAB','Кабель',6],[2,'CLP','Клемма',2]] as $item) {
        p179Command($db,'INSERT INTO '.$table.' (id,sku,title,qty) VALUES (?,?,?,?)',$item);
    }
    $deadline=1;$timed=null;$close=null;
    $timeoutSet=$db->setAttribute(Pdo\Dblib::ATTR_QUERY_TIMEOUT,$deadline);
    $start=hrtime(true);
    try {
        $failure=p179Capture(function() use ($db,$table,&$timed):void {
            $timed=$db->prepare("WAITFOR DELAY '00:00:02'; SELECT TOP (2) sku,title,qty FROM ".$table.' ORDER BY id');
            $timed->execute();
        });
        $elapsed=(hrtime(true)-$start)/1e9;
    } finally {
        $close=p179Capture(function() use (&$timed):void {p179Release($timed);});
        p179Close($db);
        // После тайм-аута не полагаемся на пригодность прежнего DB-Library соединения.
        // Новый профиль также возвращает общий query timeout к выбранным 10 секундам.
        $db=p179Connect($config);
    }
    p179AssertDatabase($db);
    $raw=p179Read($db,'SELECT TOP (2) sku,title,qty FROM '.$table.' WHERE qty>0 ORDER BY id');
    $items=array_map(static fn(array $r):array=>['sku'=>$r['sku'],'title'=>$r['title'],'qty'=>(int)$r['qty']],$raw);
    $budget=['query_timeout_seconds'=>$deadline,'delayed_query'=>$failure['failed']?'rejected':'completed',
        'error'=>['state'=>$failure['state'],'code'=>$failure['code']],
        'recovery'=>'new_connection','items'=>$items];
    saveJson179('budget.json',$budget);
    $checks=[
        'timeout_setting_accepted'=>$timeoutSet===true,
        'controlled_delay_failed'=>$failure['failed']===true,
        'timeout_diagnostic'=>$failure['state']==='HY000' && $failure['code']===20003,
        'bounded_wait'=>$elapsed>=0.5 && $elapsed<8.0,
        'failed_statement_released'=>$timed===null && $close['failed']===false,
        'new_connection_ready'=>$db instanceof Pdo\Dblib,
        'own_catalog_read'=>$items===[['sku'=>'CAB','title'=>'Кабель','qty'=>6],['sku'=>'CLP','title'=>'Клемма','qty'=>2]],
        'stock_unchanged'=>array_sum(array_column($items,'qty'))===8,
        'no_transaction_open'=>!$db->inTransaction(),
        'artifact_matches'=>json_decode(file_get_contents(__DIR__.'/budget.json'),true,512,JSON_THROW_ON_ERROR)===$budget];
    $rows=[['Бюджет SQL, секунд',$deadline],['Медленный отчёт отклонён',$failure['failed']],
        ['Диагностика',$failure['state'].'/'.$failure['code']],['Восстановление','новое соединение'],
        ['Доступных товаров',count($items)],['Общий остаток',array_sum(array_column($items,'qty'))],['Файл','budget.json']];
    $observations=['budget'=>$budget,'elapsed_seconds'=>$elapsed,'close_cursor'=>$close,
        'fresh_rows'=>$raw,'fresh_profile_query_timeout_seconds'=>10,
        'recovery_policy'=>'release_statement_close_connection_open_new_read_only_query'];

} finally {

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

p179Report('pdo179-query-budget','Не ждать медленный отчёт: срок запроса и новый сеанс для каталога',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Бюджет SQL, секунд — 1
Медленный отчёт отклонён — да
Диагностика — HY000/20003
Восстановление — новое соединение
Доступных товаров — 2
Общий остаток — 8
Файл — budget.json

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

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

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

Проверенная связка после такого тайм-аута отвергала запрос на прежнем соединении с HY000/20047; поэтому учебная политика сразу создаёт новое. Это конкретное наблюдение FreeTDS 1.3.17, а не общий закон всех PDO-драйверов. Отказ delayed SELECT не является основанием автоматически повторять изменяющий SQL. Проект не воспроизводит обрыв сети или отмену уже зафиксированной операции. dbsettime действует на уровень библиотеки процесса: открытие нового PDO с options возвращает выбранную общую политику, но не создаёт независимый таймер на каждый объект.

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

Предположите, что вместо SELECT задержался INSERT заказа. Можно ли после открытия нового PDO просто выполнить тот же INSERT ещё раз?

Разбор

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

Различить отказ маршрута, пустой результат и нарушение контракта

Успешное подключение решает только первую часть задачи. После него проверяют выбранную базу, права, текст и окончательный результат операции. Сохраняйте исключение сразу у отказавшего вызова; следующий успешный запрос может изменить диагностику соединения. Публичный ответ не должен печатать реквизиты, пути FreeTDS, полный SQL со значениями или сырой errorInfo.

Что произошлоКак отличить и что делать
PDO не находит dblibПроверить выбранный PHP и PDO::getAvailableDrivers(), загруженное расширение/статическую сборку. Пароль ещё не проверялся.
Маршрут или вход не выполненСверить конфигурацию FreeTDS, host/порт/базу, доступность и реквизиты. Коды и SQLSTATE приходят от конкретной версии; не переносить HYT00 PDO_SQLSRV как обязательный код DBLIB.
getAttribute отвергает настройкуНаследуемая PDO-константа не гарантирует реализацию. Сверить таблицу драйвера; неподдержанные getters CLIENT_VERSION/SERVER_VERSION в выбранной сборке дают IM001.
SELECT вернул ноль строкДопустимый итог фильтра. Но fetch(false) не подтверждает успех всего SQL-пакета: поздняя ошибка может проявиться при nextRowset. ERRMODE_EXCEPTION помогает обработать сообщённый отказ; для пакета нужно пройти его результаты. Набор без столбцов и SELECT без строк различаются.
Кириллица выглядит верно только в одном приложенииПроверить UTF-8 на стороне PHP, тип NVARCHAR, NATL-параметры и фактическое хранение через UNICODE. Не исправлять каждый символ произвольным перекодированием.
SQL NULL неожиданно не принимаетсяВ CREATE TABLE писать NULL/NOT NULL явно. Настройки сессии и сервера могут менять умолчания; NULL не равен строке NULL или пустому тексту.
Превышен срок запросаОсвободить statement, не обещать результат неоконченной операции, выполнить выбранный путь восстановления. Для изменения данных учитывать возможность уже выполненного действия.

В модели PDO_DBLIB одна ссылка $db может удерживаться statement. Поэтому сначала завершайте или отменяйте результат, затем снимайте ссылки на statement и соединение. SQL-параметры не авторизуют доступ к записи; route из списка допустимых профилей не заменяет права базы. Параметры prepare относятся к значениям, а названия таблиц, выражения сортировки и маршруты выбирает код приложения. Точные экспортные контракты отдельно учат работать с UUID, деньгами, датами и байтами.

Продолжить от подключения к точной выгрузке и целой операции

Теперь маршрут связан с полезным каталогом, текстовые параметры — с исходными значениями, а срок ожидания — с определённым отказом. Следующая глава сопоставляет UUID, даты, точные суммы и бинарный файл. Затем обход наборов и транзакции связывают несколько действий в подтверждённую операцию. Общие методы PDO сохраняют привычные имена, но контракт конкретного драйвера нужно проверять на каждом таком переходе.

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

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

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

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

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