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

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

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

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

Глава 302 · После подключения PDO_DBLIB и первой параметризованной выборки

PDO_DBLIB: UUID, точная ведомость и восстановленный файл

Карта UUID для партнёра, точная ведомость с четырьмя знаками и восстановленное вложение: договоры PDO_DBLIB на выбранных SQL-данных.

Какой договор должен сохранить файл

PDO позволяет PHP отправить запрос и получить строки. В PDO_DBLIB запрос проходит через клиентскую библиотеку FreeTDS и протокол TDS к SQL Server. Поэтому переносимые вызовы PDO соседствуют с собственными правилами типов, ожидания и завершения результатов.

Проекты запускаются через отдельный Linux PHP с PDO_DBLIB и FreeTDS в собственной учебной базе SQL Server. Начало SQL и PDO объясняет таблицу, запрос и получение строки, а глава подключения PDO_DBLIB — устройство маршрута и подготовку собственного окружения.

Выгрузка нужна другому человеку или программе: партнёр сопоставляет документы по UUID, расчётная система принимает точную сумму, сотрудник открывает сохранённое вложение. Успешный SELECT ещё не доказывает, что цифры, время и байты сохранили свой смысл. Эта глава задаёт договор каждого файла и проверяет его на конкретных значениях.

Что получает читательИсходные данныеГде используется
document-map.csvDOC-1042 → UUID 00112233-4455-6677-8899-AABBCCDDEEFFСопоставление выбранной партии
ledger.csv123456789012345.6789; номер 9007199254740993; дробные секундыТочная ведомость дня
attachment.bin13 байт с NUL/FF, плюс отдельные empty/NULLПеренос одного файла
Три договора между базой и получателем
  1. SQL-тип: uniqueidentifier / decimal(19,4) / varbinary(max)
  2. Драйвер: PHP string, int или float; правила FreeTDS и PDO_DBLIB
  3. Договор приложения: UUID-текст / четыре десятичных знака / исходные байты
  4. Файл и видимый HTML: выбранные строки, точные значения, проверяемый результат

Это разные уровни. SQL-тип описывает хранение, PHP-тип — значение после преобразования, а CSV не хранит SQL-схему сам по себе. Например, точный SQL DECIMAL нельзя автоматически считать точным PHP float. Сначала прочитайте минимальную операцию, затем откройте полный проект с собственной таблицей. Его README описывает окружение и очистку; архив не требует таблиц из соседнего примера.

Драйверные наблюдения ниже относятся к отдельной проверочной связке: Linux PHP 8.5.11, PDO_DBLIB 8.5.11, FreeTDS 1.3.17, TDS 7.4 и SQL Server 17.0.5035.5. Это проверенный договор представления конкретного окружения, а не обещание одинакового результата всех версий FreeTDS или других PDO-драйверов. Собственные таблицы и файлы проектов дают возможность проверить этот договор у себя.

Предварительные понятия: соединение, параметры и чтение строки, FreeTDS, DSN и кодировка. Общий справочник PDO_DBLIB сохраняет канонический обзор; здесь показаны предметные применения. Код копируется отдельно от пояснений и не содержит паролей или частных путей.

Значение параметра и способ получения результата

Для карты документов сначала достаточно узнать один бизнес-код. Таблица и список колонок уже выбраны программистом; значение DOC-1042 передаёт пользователь или следующая часть приложения. Параметр отделяет это значение от синтаксиса SQL, включая апостроф в названии.

$s = $db->prepare('SELECT document_code, title FROM documents WHERE document_code = ?');
$s->bindValue(1, 'DOC-1042', PDO::PARAM_STR | PDO::PARAM_STR_CHAR);
$s->execute();
$row = $s->fetch(PDO::FETCH_ASSOC);
$s->closeCursor();
// Полный проект создаёт DOC-1042 с названием "Заявка O'Reilly".

Вызов prepare принимает SQL-строку и необязательные опции драйвера, возвращает PDOStatement либо сообщает ошибку. В SQL знак ? обозначает одно целое значение. В bindValue номер 1 — первый маркер, далее значение и его тип. execute выполняет команду с уже привязанными значениями; fetch читает строку. Точка с запятой завершает PHP-инструкцию, -> обращается к методу объекта. При ERRMODE_EXCEPTION ошибку запроса представляет PDOException; отсутствие найденной строки — отдельное false от fetch.

В закреплённом исходнике PHP 8.5.11 PDO_DBLIB объявляет PDO_PLACEHOLDER_NONE: подготовка эмулируется общим механизмом PDO. Это по-прежнему параметризация значений, но она не означает серверный prepared statement и не обещает, что prepare уже проверил SQL на сервере. В исходнике quoter удваивает апостроф; поэтому название O'Reilly не нужно предварительно менять на O''Reilly в прикладном массиве. Повторное экранирование испортило бы текст.

ЗначениеТип и назначениеГраница
id = 1PDO::PARAM_INTЦелое значение для SQL-параметра; не имя колонки
title = «Заявка O'Reilly»PDO::PARAM_STR | PDO::PARAM_STR_NATLНациональный текст для NVARCHAR; кодировка соединения всё равно должна быть согласована
hex = 4100ffPDO::PARAM_STR | PDO::PARAM_STR_CHARASCII-текст для явного CONVERT(...,2), не raw binary
NULLPDO::PARAM_NULLОтсутствующее значение; пустая строка — другой ввод
amount = «123456789012345.6789»Точная десятичная строкаНе преобразовывать в float перед передачей; SQL-колонка задаёт precision/scale

DEFAULT_STR_PARAM задаёт обычный строковый режим соединения; конкретный bindValue может явно выбрать NATL/CHAR. В общих helpers этой лаборатории исходный режим — NATL, поэтому ASCII hex помечается CHAR непосредственно. Имя таблицы или направление сортировки нельзя передать маркером: для них нужен ограниченный автором набор SQL-вариантов. Случайные учебные имена проходят отдельную проверку p179Qualified и не берутся от пользователя.

Первое применение — партия документов, второе — ASCII-вход вложения. Подробнее: PDO::prepare, PDOStatement::bindValue и PDOStatement::execute. Обработка полной операции с commit/rollback находится в соседней главе.

UUID: двоичная ссылка и читаемый ключ

uniqueidentifier хранит UUID, а не строку с дефисами. Партнёру обычно нужен читаемый текст, например 00112233-4455-6677-8899-AABBCCDDEEFF. Полученные 16 байт и полученные 36 символов описывают одну ссылку разными способами; вставлять первый вариант прямо в UTF-8 JSON нельзя.

Настройка соединенияКонтракт по исходнику PHP 8.5.11Два применения
Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER = falseКопирование 16 байт uniqueidentifier в PHP stringДиагностика длины/hex и проверка миграции формата без вывода сырых байтов
Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER = trueПреобразование UUID в 36-символьный текстCSV-карта партнёру и поле ссылки в JSON-ответе приложения
SQL NULLОтсутствующая ссылка, отдельная от UUIDКолонка link_state и фильтр request_uuid IS NOT NULL

setAttribute принимает код атрибута и значение; его общий результат — bool, а ошибки при ERRMODE_EXCEPTION нужно учитывать как исключения. getAttribute возвращает текущее значение либо сообщает неподдержанный атрибут; это не метод PDOStatement. Сохраняйте исходную настройку и восстанавливайте её в finally, если следующий код использует то же соединение. Новые пространства Pdo\Dblib доступны с PHP 8.4; старое имя PDO::DBLIB_ATTR_STRINGIFY_UNIQUEIDENTIFIER относится к тому же драйверному семейству, а не к другому алгоритму.

$saved = $db->getAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER);
try {
    $db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER, true);
    // Здесь полностью прочитайте и завершите нужную выборку.
} finally {
    $db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER, $saved);
}

Полный проект карты использует один и тот же набор: сначала безопасное описание binary-режима, затем текстовую выгрузку. strtoupper задаёт единый регистр уже текстового UUID, а не декодирует его. Проверка формы UUID нужна для файлового договора; она не проверяет существование документа, владельца или разрешение доступа. Параметризованный WHERE по бизнес-коду также не заменяет авторизацию.

Подготовка подтвердила: для UUID 00112233-4455-6677-8899-AABBCCDDEEFF режим false дал 16 байт с hex 33221100554477668899aabbccddeeff, а true — 36 символов канонического текста. SQL NULL остался null в обоих режимах. Разница порядка первых байтов показывает, почему простая вставка дефисов в bin2hex не является правильным универсальным преобразованием UUID.

Вопрос о UUID после смены версии PHP показывает потребность в явном формате: старая программа ждала текст и получила нечитаемые данные. Решение здесь опирается на современный атрибут и проверяемый результат, без понижения PHP и без догадки о порядке байтов GUID.

Передать партнёру карту документов с читаемыми UUID

Партнёр сопоставляет документы по вашему document_code и своему request_uuid. Нужен небольшой CSV конкретной партии: русские названия читаются, UUID записан текстом, а отсутствующая ссылка не стала нулевым идентификатором. Полный проект создаёт четыре документа и отдаёт только три из BATCH-42.

Путь данных и полученный результат
  1. BATCH-42: DOC-1042, DOC-1043, DOC-1044
  2. SQL WHERE выбирает партию; ORDER BY id задаёт порядок
  3. UUID: 16 байт в одном режиме → текст в другом; SQL NULL сохраняется
  4. document-map.csv: 4 колонки, 3 документа; другая партия не попала

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

$db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER, true);
$s = $db->prepare('SELECT request_uuid FROM documents WHERE document_code = ?');
$s->execute(['DOC-1042']);
$row = $s->fetch(PDO::FETCH_ASSOC);
$s->closeCursor();
if ($row !== false && $row['request_uuid'] !== null) {
    echo strtoupper($row['request_uuid']);
}
// Для документа проекта: 00112233-4455-6677-8899-AABBCCDDEEFF

setAttribute настраивает получение uniqueidentifier на этом соединении. Константа Pdo\Dblib относится к драйверу; true включает строковый вид. Знак ? заменяет одно значение document_code при execute. FETCH_ASSOC даёт массив с ключом request_uuid. Строгое !== false отделяет отсутствие строки, а !== null — отсутствие ссылки внутри существующего документа. Минифрагмент предполагает таблицу проекта; полный ZIP создаёт её сам.

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

НаблюдениеРезультат
ПартияBATCH-42: 3 документа
DOC-1042Заявка O'Reilly | 00112233-4455-6677-8899-AABBCCDDEEFF
DOC-1043Склад Север | 11111111-2222-3333-4444-555555555555
DOC-1044Без привязки | ссылка отсутствует
Пустая партия0 строк, те же 3 выбранные колонки
Файлdocument-map.csv: заголовок и 3 документа
Самостоятельный проект, исходник и архив
Передать партнёру карту документов с читаемыми UUIDСамостоятельный пример
<?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 writeCsv179(string $filename,array $records): void {
    $file=fopen(__DIR__.'/'.$filename,'wb');
    if ($file===false) {throw new RuntimeException('Не удалось открыть CSV.');}
    try {
        foreach ($records as $record) {
            if (fputcsv($file,$record,';','"','',"\n")===false) {
                throw new RuntimeException('Не удалось записать строку CSV.');
            }
        }
    } finally {fclose($file);}
}

    $name=p179OwnTable($db,$owned,'guid_map',
        '(id int PRIMARY KEY,batch_code varchar(20) NOT NULL,document_code varchar(20) NOT NULL UNIQUE,title nvarchar(80) NOT NULL,request_uuid uniqueidentifier NULL)');
    $t=p179Qualified($name);
    foreach ([
        [1,'BATCH-42','DOC-1042',"Заявка O'Reilly",'00112233-4455-6677-8899-aabbccddeeff'],
        [2,'BATCH-42','DOC-1043','Склад Север','11111111-2222-3333-4444-555555555555'],
        [3,'BATCH-42','DOC-1044','Без привязки',null],
        [4,'BATCH-43','DOC-1045','Другая партия','aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'],
    ] as $record) {
        p179Command($db,'INSERT INTO '.$t.' (id,batch_code,document_code,title,request_uuid) VALUES (?,?,?,?,?)',$record);
    }
    $saved=$db->getAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER);
    $s=null;$raw=[];$selected=[];$metadata=[];
    $records=[['document_code','title','request_uuid','link_state']];
    try {
        $db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER,false);
        $raw=p179Read($db,'SELECT request_uuid FROM '.$t.' WHERE batch_code=? ORDER BY id',['BATCH-42']);
        // Двоичные UUID остаются только в безопасном hex-наблюдении.
        $rawDescription=array_map(static fn(array $r): array=>[
            'type'=>get_debug_type($r['request_uuid']),
            'bytes'=>is_string($r['request_uuid'])?strlen($r['request_uuid']):null,
            'hex'=>is_string($r['request_uuid'])?bin2hex($r['request_uuid']):null,
        ],$raw);
        $db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER,true);
        $sql='SELECT document_code AS document_code,title AS title,request_uuid AS request_uuid FROM '.$t.' WHERE batch_code=? ORDER BY id';
        $s=$db->prepare($sql);
        $s->execute(['BATCH-42']);
        for ($i=0;$i<$s->columnCount();$i++) {
            $m=$s->getColumnMeta($i);
            if ($m===false) {throw new UnexpectedValueException('Нет описания выбранной колонки.');}
            $metadata[]=['name'=>$m['name'],'native_type'=>$m['native_type']??null,
                'pdo_type'=>$m['pdo_type']??null,'precision'=>$m['precision']??null,'scale'=>$m['scale']??null];
        }
        if (array_column($metadata,'name')!==['document_code','title','request_uuid']) {
            throw new UnexpectedValueException('Заголовки результата не соответствуют договору.');
        }
        while (($row=$s->fetch(PDO::FETCH_ASSOC))!==false) {
            $uuid=$row['request_uuid'];
            if ($uuid!==null && (!is_string($uuid) || !preg_match('/^[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$/D',$uuid))) {
                throw new UnexpectedValueException('Нужен текстовый UUID, а не произвольные байты.');
            }
            $selected[]=$row;
            $records[]=[$row['document_code'],$row['title'],$uuid===null?'':strtoupper($uuid),$uuid===null?'NULL':'VALUE'];
        }
        p179Release($s);
        // Отсутствие совпадений не отменяет договор колонок.
        $s=$db->prepare($sql);$s->execute(['ABSENT']);
        $emptyNames=[];
        for ($i=0;$i<$s->columnCount();$i++) {$emptyNames[]=$s->getColumnMeta($i)['name'];}
        $emptyRow=$s->fetch(PDO::FETCH_ASSOC);
    } finally {
        p179Release($s);
        $db->setAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER,$saved);
    }
    writeCsv179('document-map.csv',$records);
    $checks=[
        'batch_selected'=>array_column($selected,'document_code')===['DOC-1042','DOC-1043','DOC-1044'],
        'raw_uuid_bounded'=>count($raw)===3 && is_string($raw[0]['request_uuid']) && strlen($raw[0]['request_uuid'])===16 && bin2hex($raw[0]['request_uuid'])==='33221100554477668899aabbccddeeff' && $raw[2]['request_uuid']===null,
        'uuid_text_exact'=>$records[1][2]==='00112233-4455-6677-8899-AABBCCDDEEFF' && $records[2][2]==='11111111-2222-3333-4444-555555555555',
        'unicode_quote_exact'=>$selected[0]['title']==="Заявка O'Reilly" && $selected[1]['title']==='Склад Север',
        'null_kept'=>$records[3][2]==='' && $records[3][3]==='NULL',
        'empty_schema_kept'=>$emptyRow===false && $emptyNames===['document_code','title','request_uuid'],
        'setting_restored'=>$db->getAttribute(Pdo\Dblib::ATTR_STRINGIFY_UNIQUEIDENTIFIER)===$saved,
        'csv_written'=>is_file(__DIR__.'/document-map.csv') && filesize(__DIR__.'/document-map.csv')>0,
    ];
    $rows=[['Партия','BATCH-42: 3 документа'],
        ['DOC-1042',$records[1][1].' | '.$records[1][2]],
        ['DOC-1043',$records[2][1].' | '.$records[2][2]],
        ['DOC-1044',$records[3][1].' | ссылка отсутствует'],
        ['Пустая партия','0 строк, те же 3 выбранные колонки'],
        ['Файл','document-map.csv: заголовок и 3 документа']];
    $observations=['selected_rows'=>$selected,'raw_uuid_description'=>$rawDescription,
        'column_contract'=>$metadata,'empty_column_names'=>$emptyNames,'csv_records'=>$records];

} finally {

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

p179Report('pdo179-guid-map','Передать партнёру карту документов с читаемыми UUID',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Партия — BATCH-42: 3 документа
DOC-1042 — Заявка O'Reilly | 00112233-4455-6677-8899-AABBCCDDEEFF
DOC-1043 — Склад Север | 11111111-2222-3333-4444-555555555555
DOC-1044 — Без привязки | ссылка отсутствует
Пустая партия — 0 строк, те же 3 выбранные колонки
Файл — document-map.csv: заголовок и 3 документа

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

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

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

Один UUID не предоставляет право читать документ: проверка пользователя и партии нужна отдельно. Регистр текста нормализуется явно; raw GUID не преобразуется ручной перестановкой байтов. Не помещайте двоичный GUID в JSON как UTF-8. Договор aliases позволяет проверить заголовки пустого результата, но не превращает getColumnMeta в полное описание SQL-схемы. Настройка соединения восстановлена после чтения.

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

Потребитель хочет получить только документы, у которых уже есть request_uuid. Где изменить правило так, чтобы PHP не выгружал лишние документы?

Разбор

Добавьте AND request_uuid IS NOT NULL к WHERE с параметром партии. У BATCH-42 останутся DOC-1042 и DOC-1043. Сохраните ORDER BY id и те же aliases; пересчитайте ожидаемые строки CSV. Состояние SQL NULL продолжает быть явной частью договора, даже когда этот фильтр исключает его из конкретного файла.

Четыре знака суммы, большие номера и формат времени

У суммы 123456789012345.6789 важна последняя цифра. SQL DECIMAL(19,4) хранит её точно, но в исходнике PDO_DBLIB числовая ветка по умолчанию преобразует DECIMAL/NUMERIC/MONEY в double. Такой результат не подходит как промежуточное представление точной ведомости. Включите строковое получение до выборки или явно выберите SQL-текст с нужным договором.

В подготовке та же сумма без STRINGIFY_FETCHES стала PHP float со значением 123456789012345.67. С флагом true получена точная строка 123456789012345.6789, а SQL SUM вернул строку 123456789012345.6788. BIGINT 9007199254740993 на проверенном 64-битном PHP пришёл как int без флага и как строка с флагом. Эти результаты не переносятся на 32-битную сборку; для файла в любом случае выбран явный строковый договор.

ДанныеДоговор приложенияЧто не делать
DECIMAL(19,4)Строка с четырьмя знаками; SQL SUM возвращает точный итогНе «восстанавливать» потерянную цифру через округление float
BIGINT 9007199254740993Идентификатор в CSV строкой без арифметикиНе пропускать номер через double или автоматическое числовое распознавание Excel
NULL / пустая строка / «0»Три разных состояния пометкиНе объединять через empty(), ?: или нестрогое сравнение
datetime2(7)Календарная дата/время и 7 дробных знаков без зоныНе придумывать смещение по локальным настройкам PHP
datetimeoffset(7)Дата/время вместе с сохранённым смещениемНе считать смещение названием зоны с историей DST
$db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true);
$s = $db->prepare('SELECT CAST(SUM(amount) AS decimal(19,4)) AS total FROM ledger WHERE occurred_at >= ? AND occurred_at < ?');
$s->execute(['2026-11-08', '2026-11-09']);
$total = $s->fetchColumn();
$s->closeCursor();
// Набор полного проекта: "123456789012345.6788"; расчёт выполняется SQL.

Это второе применение строкового числового контракта после отдельных проводок: вычисленная сумма проходит тот же безопасный путь. SUM над пустым набором возвращает NULL; значение «нет операций» надо выбрать явно, а не автоматически считать строкой нуля. Четыре знака задаёт DECIMAL(19,4). Преобразование строки вида -.0001 в -0.0001 разрешено только как изменение записи без арифметики; fixedFour179 проверяет форму и не округляет.

Pdo\Dblib::ATTR_DATETIME_CONVERT управляет представлением даты на уровне драйвера. PHP Manual различает стандартный SQL Server формат при false и формат FreeTDS/locales.conf при true. В закреплённой ветке false некоторые datetime-типы проходят SQLDATETIME и формат до секунд; поэтому нельзя автоматически обещать сохранение всех дробных знаков datetime2. Полный проект ведомости сохраняет native-варианты как наблюдения, а контракт файла задаёт явно выбранной ISO-колонкой SQL.

Контрольное значение подготовкиПолучено через драйверДоговор обмена
datetime2: 2026-11-08T12:34:56.1234567; флаг false2026-11-08 12:34:56CONVERT(...,126): 2026-11-08T12:34:56.1234567
То же datetime2; флаг trueNov 8 2026 12:34:56:1234567PMISO-колонка осталась той же
datetimeoffset: то же время с +03:00; оба флагаNov 8 2026 12:34:56:1234567PM — смещение в строке отсутствуетCONVERT(...,126): 2026-11-08T12:34:56.1234567+03:00

Это наблюдение особенно важно для datetimeoffset: по native-строке здесь нельзя восстановить исходное смещение. В SQL оно сохранилось, что подтвердил отдельный текстовый столбец. При нулевой дробной части CONVERT(...,126) в подготовке опустил точку и нули; формат ISO не означает обязательные семь напечатанных знаков. Проект использует ненулевые дробные части, а NULL сохраняется отдельным значением.

$s = $db->prepare('SELECT CONVERT(varchar(40), occurred_at, 126) AS occurred_iso FROM ledger WHERE entry_code = ?');
$s->execute(['PAY-701']);
$iso = $s->fetchColumn();
$s->closeCursor();
// Для строки проекта: 2026-11-08T09:10:11.1234567

varchar(40) явно задаёт длину, 126 выбирает ISO-запись, AS occurred_iso — имя выбранной колонки. Это другое представление уже сохранённого значения; оно не переводит момент в UTC. Два применения — стабильная CSV-колонка и сравнение native-вида при смене locale. В FreeTDS locales.conf спецификатор %z означает миллисекунды, поэтому его нельзя объяснять как смещение часового пояса. Конфигурация лаборатории частная; системный locale-файл не меняется.

Обсуждение разных форматов PDO_DBLIB и tsql служит постановкой задачи: одинаковая база ещё не гарантирует одинаковую строку даты в разных клиентах. Нормативные правила взяты из PHP/FreeTDS и SQL Server, а не из старых советов об окружении. Типы, формат и границы явного SQL-преобразования проверяются вместе.

Сохранить четыре десятичных знака и время проводок

Система расчётов принимает суммы с четырьмя знаками после точки и большие номера документов. Испорченная последняя цифра меняет сумму или связь с документом. Проект формирует ведомость одного дня: три строки, точный SQL-итог, дробные секунды и отдельно сохранённое смещение времени.

Путь данных и полученный результат
  1. PAY-701: строка 123456789012345.6789 → DECIMAL(19,4)
  2. WHERE 2026-11-08 ≤ occurred_at < 2026-11-09 выбирает 3 строки
  3. STRINGIFY_FETCHES и SQL ISO-текст сохраняют цифры; SUM выполняется в SQL
  4. ledger.csv содержит 3 проводки; HTML показывает итог 123456789012345.6788

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

$db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true);
$s = $db->prepare('SELECT amount FROM ledger WHERE entry_code = ?');
$s->execute(['PAY-701']);
$amount = $s->fetchColumn();
$s->closeCursor();
if (!is_string($amount)) { throw new RuntimeException('Нужна точная строка суммы.'); }
echo $amount;
// Значение проекта: 123456789012345.6789

ATTR_STRINGIFY_FETCHES — настройка PDO, которая в этом драйвере участвует в числовом преобразовании. true выбирает текст; это не команда SQL и не округление. fetchColumn возвращает одну колонку первой строки. is_string проверяет договор перед экспортом. Четыре цифры после точки принадлежат DECIMAL(19,4); PHP не переводит их в float ни при записи, ни при суммировании.

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

НаблюдениеРезультат
Период2026-11-08: 3 проводки
PAY-7019007199254740993 | 123456789012345.6789
PAY-7029007199254740994 | -0.0001
PAY-7039007199254740995 | 0.0000
Итог SQL123456789012345.6788
Время PAY-7012026-11-08T09:10:11.1234567
Время со смещением2026-11-08T12:10:11.1234567+03:00
ПометкиNULL / EMPTY / VALUE: 0
Файлledger.csv: заголовок и 3 проводки
Самостоятельный проект, исходник и архив
Сохранить четыре десятичных знака и время проводокСамостоятельный пример
<?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 writeCsv179(string $filename,array $records): void {
    $file=fopen(__DIR__.'/'.$filename,'wb');
    if ($file===false) {throw new RuntimeException('Не удалось открыть CSV.');}
    try {
        foreach ($records as $record) {
            if (fputcsv($file,$record,';','"','',"\n")===false) {
                throw new RuntimeException('Не удалось записать строку CSV.');
            }
        }
    } finally {fclose($file);}
}

function fixedFour179($value): string {
    if (!is_string($value)) {throw new UnexpectedValueException('Точная сумма должна быть строкой.');}
    if (str_starts_with($value,'-.')) {$value='-0'.substr($value,1);}
    elseif (str_starts_with($value,'.')) {$value='0'.$value;}
    if (!preg_match('/^-?[0-9]+\.[0-9]{4}$/D',$value)) {throw new UnexpectedValueException('Нужны четыре десятичных знака.');}
    return $value;
}
    $name=p179OwnTable($db,$owned,'ledger',
        '(id int PRIMARY KEY,entry_code varchar(20) NOT NULL,document_number bigint NOT NULL,occurred_at datetime2(7) NOT NULL,offset_time datetimeoffset(7) NOT NULL,amount decimal(19,4) NOT NULL,note nvarchar(40) NULL)');
    $t=p179Qualified($name);
    foreach ([
        [1,'PAY-701','9007199254740993','2026-11-08T09:10:11.1234567','2026-11-08T12:10:11.1234567+03:00','123456789012345.6789',null],
        [2,'PAY-702','9007199254740994','2026-11-08T10:20:30.7654321','2026-11-08T13:20:30.7654321+03:00','-0.0001',''],
        [3,'PAY-703','9007199254740995','2026-11-08T11:00:00.1234567','2026-11-08T14:00:00.1234567+03:00','0.0000','0'],
        [4,'PAY-704','9007199254740996','2026-11-09T09:00:00.1234567','2026-11-09T12:00:00.1234567+03:00','17.0000','Следующий день'],
    ] as $record) {
        p179Command($db,'INSERT INTO '.$t.' (id,entry_code,document_number,occurred_at,offset_time,amount,note) VALUES (?,?,?,?,?,?,?)',$record);
    }
    $savedStringify=$db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES);
    $savedDate=$db->getAttribute(Pdo\Dblib::ATTR_DATETIME_CONVERT);
    $s=null;$data=[];$records=[['entry_code','document_number','occurred_at','offset_time','amount','note_state','note']];
    try {
        // Не использовать этот default-результат для денежных расчётов.
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,false);
        $defaultSample=p179Read($db,'SELECT amount,document_number FROM '.$t.' WHERE id=1')[0];
        $defaultTypes=array_map('get_debug_type',$defaultSample);
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,true);
        $sql='SELECT entry_code,document_number,amount,CONVERT(varchar(40),amount) AS amount_text,'
            .'CONVERT(varchar(40),occurred_at,126) AS occurred_iso,'
            .'CONVERT(varchar(40),offset_time,126) AS offset_iso,note FROM '.$t
            .' WHERE occurred_at>=? AND occurred_at<? ORDER BY occurred_at,id';
        $s=$db->prepare($sql);$s->execute(['2026-11-08','2026-11-09']);
        while (($r=$s->fetch(PDO::FETCH_ASSOC))!==false) {
            $amount=fixedFour179($r['amount']);
            if ($amount!==$r['amount_text'] || !is_string($r['document_number'])) {
                throw new UnexpectedValueException('Числовой договор выгрузки изменился.');
            }
            $state=$r['note']===null?'NULL':($r['note']===''?'EMPTY':'VALUE');
            $records[]=[$r['entry_code'],$r['document_number'],$r['occurred_iso'],$r['offset_iso'],$amount,$state,$r['note']??''];
            $data[]=$r;
        }
        p179Release($s);
        $sum=p179Read($db,'SELECT CAST(SUM(amount) AS decimal(19,4)) AS total FROM '.$t.' WHERE occurred_at>=? AND occurred_at<?',['2026-11-08','2026-11-09'])[0]['total'];
        $total=fixedFour179($sum);
        $dateVariants=[];
        foreach ([false,true] as $convert) {
            $db->setAttribute(Pdo\Dblib::ATTR_DATETIME_CONVERT,$convert);
            $d=p179Read($db,'SELECT occurred_at,CONVERT(varchar(40),occurred_at,126) AS iso FROM '.$t.' WHERE id=1')[0];
            $dateVariants[]=['datetime_convert'=>$convert,'native_value'=>$d['occurred_at'],'type'=>get_debug_type($d['occurred_at']),'sql_iso'=>$d['iso']];
        }
    } finally {
        p179Release($s);
        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES,$savedStringify);
        $db->setAttribute(Pdo\Dblib::ATTR_DATETIME_CONVERT,$savedDate);
    }
    writeCsv179('ledger.csv',$records);
    $checks=[
        'day_selected'=>array_column($data,'entry_code')===['PAY-701','PAY-702','PAY-703'],
        'bigint_exact'=>$records[1][1]==='9007199254740993',
        'default_numeric_types'=>$defaultTypes===['amount'=>'float','document_number'=>'int'],
        'four_decimals_exact'=>array_column(array_slice($records,1),4)===['123456789012345.6789','-0.0001','0.0000'],
        'sql_sum_exact'=>$total==='123456789012345.6788',
        'fraction_preserved'=>$records[1][2]==='2026-11-08T09:10:11.1234567',
        'offset_preserved'=>$records[1][3]==='2026-11-08T12:10:11.1234567+03:00',
        'null_empty_zero'=>array_column(array_slice($records,1),5)===['NULL','EMPTY','VALUE'] && $records[3][6]==='0',
        'locale_does_not_change_iso'=>$dateVariants[0]['sql_iso']===$dateVariants[1]['sql_iso'],
        'settings_restored'=>$db->getAttribute(PDO::ATTR_STRINGIFY_FETCHES)===$savedStringify && $db->getAttribute(Pdo\Dblib::ATTR_DATETIME_CONVERT)===$savedDate,
        'csv_written'=>is_file(__DIR__.'/ledger.csv') && filesize(__DIR__.'/ledger.csv')>0,
    ];
    $rows=[['Период','2026-11-08: 3 проводки'],
        ['PAY-701',$records[1][1].' | '.$records[1][4]],
        ['PAY-702',$records[2][1].' | '.$records[2][4]],
        ['PAY-703',$records[3][1].' | '.$records[3][4]],
        ['Итог SQL',$total],['Время PAY-701',$records[1][2]],
        ['Время со смещением',$records[1][3]],['Пометки','NULL / EMPTY / VALUE: 0'],
        ['Файл','ledger.csv: заголовок и 3 проводки']];
    $observations=['selected_rows'=>$data,'csv_records'=>$records,'total'=>$total,
        'default_numeric_types'=>$defaultTypes,'driver_date_variants'=>$dateVariants,
        'contract'=>'День фильтруется по occurred_at; offset_time — отдельное явно сохранённое значение.'];

} finally {

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

p179Report('pdo179-date-ledger','Сохранить четыре десятичных знака и время проводок',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Период — 2026-11-08: 3 проводки
PAY-701 — 9007199254740993 | 123456789012345.6789
PAY-702 — 9007199254740994 | -0.0001
PAY-703 — 9007199254740995 | 0.0000
Итог SQL — 123456789012345.6788
Время PAY-701 — 2026-11-08T09:10:11.1234567
Время со смещением — 2026-11-08T12:10:11.1234567+03:00
Пометки — NULL / EMPTY / VALUE: 0
Файл — ledger.csv: заголовок и 3 проводки

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

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

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

datetime2 хранит местное календарное время без часового пояса; datetimeoffset хранит также смещение, но не название зоны с её историей правил. CSV здесь сохраняет оба явных столбца и не утверждает, что они автоматически обозначают один момент. Формат FreeTDS не должен менять контракт обменного ISO-столбца. Упражнение ограничено собственными значениями; при открытии CSV длинные идентификаторы импортируйте как текст. Для произвольных пользовательских значений отдельно нужна политика защиты от формул табличного приложения.

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

Требуется ведомость не по локальному occurred_at, а по единому дню UTC. Достаточно ли заменить формат даты на другой?

Разбор

Нет: формат меняет запись, а не правило отбора. Сначала договоритесь, что источником момента служит offset_time; преобразуйте его к выбранному UTC-контракту средствами SQL и задайте границы именно этого момента. Отдельно проверьте записи около полуночи. После этого измените SQL WHERE и ожидаемые строки. Точный DECIMAL и способ получения суммы остаются прежними.

Aliases, метаданные и границы выбранной выгрузки

CSV — последовательность строк с полями. До записи нужно договориться, какие колонки нужны, в каком порядке они идут и как обозначается отсутствие значения. Такой договор остаётся полезным даже тогда, когда выбранная партия пуста. Поэтому заголовок задаётся заранее, а aliases сверяются с метаданными выполненного SELECT.

$meta = $s->getColumnMeta(0);
if ($meta === false || $meta['name'] !== 'document_code') {
    throw new UnexpectedValueException('Неверная первая колонка карты.');
}
// Индекс 0 — первая колонка результата, а не первая строка.

getColumnMeta(int $column): array|false возвращает описание колонки текущего результата. Индекс начинается с нуля. Общие PDO-поля включают name, len, precision, pdo_type и другие сведения при поддержке драйвера. Источник PDO_DBLIB добавляет max_length, scale, column_source, native_type, native_type_id и native_usertype_id. Присутствие и точные значения конкретных ключей проверяются на выбранном запросе; выражение с alias не обязано иметь источник, совпадающий с таблицей.

У проверенной выборки было десять ключей: max_length, precision, scale, column_source, native_type, native_type_id, native_usertype_id, pdo_type, name и len. Для DECIMAL(19,4) scale равнялся 4, но precision — 0: это не доказательство объявленной точности 0. column_source совпадал с alias выбранной колонки, а не сообщал проверенное имя исходной таблицы. Пустой SELECT сохранил те же колонки и metadata; fetch вернул false. Поэтому договор CSV задаётся приложением, а metadata служит проверкой результата, не источником всей схемы.

СведенияПолезный вопросГраница
name / SQL aliasПолучены ли document_code, title, request_uuid в нужном порядке?Alias не является произвольным пользовательским SQL-идентификатором
native_type / precision / scaleКак драйвер описал выбранную числовую колонку?Описание результата не заменяет полный аудит CREATE TABLE
pdo_typeКакой общий PDO-код сообщает metadata?Не объявлять его строгим PHP-типом каждого fetch; драйвер отдельно преобразует значения
column_source / max_lengthЕсть ли сведения об источнике и размере?Это не правило доступа, не право UPDATE и не длина каждого значения

В карте документов описание проверяется для непустой и пустой партии. В ведомости явные aliases отделяют native amount от amount_text, а native дату от SQL ISO. Это две разные задачи одного механизма: защитить форму обмена и сделать преобразование видимым.

SQL WHERE выбирает требуемую партию или день, ORDER BY задаёт порядок до PHP. Для более крупного набора применяйте заранее ограниченную SQL-страницу или диапазон ключа. В исходнике PDO_DBLIB fetch использует dbnextrow и не реализует перемещение по переданным FETCH_ORI_*; не переносите scroll-поведение PDO_ODBC. rowCount у SELECT не является переносимым общим количеством таблицы; пустоту проверяют fetch, а требуемый счётчик — отдельным COUNT с тем же условием.

fputcsv получает одну готовую запись, разделитель ;, кавычку и явно отключённое специальное escape-поведение. UTF-8 и LF заданы договором этих файлов. Полный урок хранит три строки для наглядного HTML; это ограниченная fixture, а не обещание выгрузить всю базу без памяти. Даже построчный цикл PHP не доказывает отсутствие буферизации FreeTDS/сервера. Закройте результат до следующей команды: PDOStatement::closeCursor не является commit.

Справочные связи: PDOStatement::getColumnMeta, PDOStatement::columnCount, PDOStatement::fetch, PDOStatement::rowCount. Как получить всю квитанцию нескольких результатов и обработать отказ, объясняет жизненный цикл операции.

Двоичный файл: явный вход и точный выход

У файла нет обязательной UTF-8-кодировки. Например, три байта 41 00 FF можно записать текстом 4100ff, но это уже шесть ASCII-символов. Для SQL-параметра проекта выбран именно такой текстовый вход; при чтении ожидаются исходные байты. Эти направления нельзя перепутать.

Вход и выход имеют разные представления
  1. Исходный файл: 41 00 FF — 3 байта
  2. bin2hex: «4100ff» — 6 ASCII-символов безопасного параметра
  3. SQL CONVERT(varbinary(max), ?, 2): снова 3 байта
  4. Выбранный VARBINARY: raw PHP string → файл; hex2bin на выходе не нужен

Почему не достаточно написать PARAM_LOB? Общий интерфейс PDO описывает большие данные, но драйверы реализуют его по-разному. В закреплённом PDO_DBLIB параметры эмулируются, quoter не имеет отдельного двоичного преобразования, а dbcmd получает C-строку. Поэтому здесь заранее выбран проверяемый ASCII-контракт и ограниченный размер. Это не скрытый запасной путь после ошибки и не утверждение, что иной маршрут LOB был проверен.

$s = $db->prepare('SELECT payload FROM attachments WHERE id = ?');
$s->bindValue(1, 1, PDO::PARAM_INT);
$s->execute();
$bytes = $s->fetchColumn();
$s->closeCursor();
if (!is_string($bytes) || strlen($bytes) > 1024) {
    throw new RuntimeException('Ожидалось маленькое двоичное вложение.');
}
if (file_put_contents(__DIR__.'/attachment.bin', $bytes) !== strlen($bytes)) {
    throw new RuntimeException('Неполная запись файла.');
}

Это второе применение после входного CONVERT: восстановить выбранное тело. В исходнике драйверная ветка BINARY/VARBINARY/IMAGE копирует значение с явной длиной, сохраняя NUL. Полный проект вложения сравнивает результат с исходной строкой, затем длину и SHA файла. Безопасный hex показывается только в диагностике HTML/JSON. Сам attachment.bin содержит исходные байты, не их печатное описание.

Подготовка подтвердила этот путь на семи исходных байтах 4D54523200FF0A: PHP string содержала ровно семь байт, включая NUL/FF. Пустой VARBINARY дал пустую строку, SQL NULL — null; серверные DATALENGTH/IS NULL различили их независимо. Полный проект использует другое собственное вложение, а его фактический файл проверяется тем же побайтным договором. Прямой LOB-параметр с NUL при этом не испытывался и не объявляется поддержанным.

СостояниеSQL-проверкаЧто сообщает интерфейс
attachment.binIS NULL = 0; DATALENGTH = 13Файл можно скачать; показаны размер и SHA
empty.binIS NULL = 0; DATALENGTH = 0Вложение есть, намеренно пустое
missing.binIS NULL = 1; DATALENGTH = NULLВложения нет

Нельзя объединять эти состояния через empty($value): оно смешивает несколько разных значений. SQL-флаг и длина задают предметный статус независимо от удобства PHP-представления. Каталог получает только имя и метаданные; SELECT payload выполняется отдельно с нужным ID. Параметр защищает синтаксис SQL, а право на конкретное вложение проверяется приложением.

FreeTDS может ограничивать text/image посредством text size. Маленький точный файл не доказывает сохранность произвольного размера и не даёт обещания потоковой передачи без буферизации. Для роста задачи задайте предел, проверьте конфигурацию и измерьте путь передачи. Вопрос о сломанном VARBINARY-изображении относится к SQLSRV: отсюда взята потребность проверить байты, но не чужой encoding flag и не совет автоматически декодировать hex.

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

Восстановить выбранное вложение без потери NUL и FF

При переносе вложения важно сохранить каждый байт. В строке PHP могут находиться NUL и FF; попытка считать её обычным UTF-8-текстом испортит файл или JSON. Этот проект сохраняет три разных состояния, выбирает тело только attachment.bin и создаёт настоящий файл с проверенным размером и SHA256.

Путь данных и полученный результат
  1. Собственные 13 байт: PDO_DBLIB, NUL, FF, 10, перевод строки
  2. bin2hex → ASCII-параметр → CONVERT(...,2) → VARBINARY(MAX)
  3. WHERE id=1 выбирает одно тело; список остальных файлов содержит только метаданные
  4. Raw bytes → attachment.bin; SHA256 и длина подтверждают точную копию

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

$source = "A\x00\xff";
$s = $db->prepare('INSERT INTO attachments (id, payload) VALUES (?, CONVERT(varbinary(max), ?, 2))');
$s->bindValue(1, 1, PDO::PARAM_INT);
$s->bindValue(2, bin2hex($source), PDO::PARAM_STR | PDO::PARAM_STR_CHAR);
$s->execute();
$s->closeCursor();
// Входной текст 4100ff превращается SQL Server в три байта 41 00 FF.

В двойных кавычках и ÿ задают конкретные байты, а bin2hex превращает их в ASCII-пары. Здесь это намеренный входной формат. CONVERT(varbinary(max), ?, 2) восстанавливает байты на сервере; 2 означает hex без префикса 0x. PARAM_STR_CHAR запрещает национальный N-префикс именно для этого ASCII-параметра. Символ | объединяет основной тип STR с его модификатором. Он не склеивает SQL. SQL NULL передаётся отдельным PARAM_NULL.

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

НаблюдениеРезультат
attachment.bin13 байт; NUL и FF сохранены
Полученные байты50444F5F44424C494200FF100A
SHA256bf345993a33529a3be5bf98a84c2281cc32cf3e85262bcda1811353e554d3e96
empty.binФайл есть: 0 байт; IS NULL = 0
missing.binВложения нет: IS NULL = 1
Файлattachment.bin: точная копия выбранного тела
Самостоятельный проект, исходник и архив
Восстановить выбранное вложение без потери NUL и FFСамостоятельный пример
<?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);

    $payload="PDO_DBLIB\x00\xff\x10\n";
    $name=p179OwnTable($db,$owned,'attachment',
        '(id int PRIMARY KEY,filename nvarchar(80) NOT NULL,payload varbinary(max) NULL)');
    $t=p179Qualified($name);$s=null;
    try {
        $s=$db->prepare('INSERT INTO '.$t.' (id,filename,payload) VALUES (?,?,CONVERT(varbinary(max),?,2))');
        foreach ([[1,'attachment.bin',$payload],[2,'empty.bin',''],[3,'missing.bin',null]] as [$id,$filename,$body]) {
            $s->bindValue(1,$id,PDO::PARAM_INT);
            $s->bindValue(2,$filename,PDO::PARAM_STR|PDO::PARAM_STR_NATL);
            if ($body===null) {$s->bindValue(3,null,PDO::PARAM_NULL);}
            else {
                $hex=bin2hex($body);
                if (strlen($hex)>2048 || strlen($hex)%2!==0 || !preg_match('/^[0-9a-f]*$/D',$hex)) {
                    throw new InvalidArgumentException('Входной hex не соответствует договору.');
                }
                $s->bindValue(3,$hex,PDO::PARAM_STR|PDO::PARAM_STR_CHAR);
            }
            $s->execute();$s->closeCursor();
        }
    } finally {p179Release($s);}
    // Список файлов не забирает тела вложений.
    $catalog=p179Read($db,'SELECT id,filename,CASE WHEN payload IS NULL THEN 1 ELSE 0 END AS is_missing,DATALENGTH(payload) AS byte_count FROM '.$t.' ORDER BY id');
    $s=$db->prepare('SELECT payload FROM '.$t.' WHERE id=?');
    try {$s->bindValue(1,1,PDO::PARAM_INT);$s->execute();$bytes=$s->fetchColumn();}
    finally {p179Release($s);}
    if (!is_string($bytes) || strlen($bytes)>1024) {throw new UnexpectedValueException('Нужно ограниченное двоичное содержимое.');}
    if ($bytes!==$payload) {throw new UnexpectedValueException('Полученные байты отличаются от исходных.');}
    $written=file_put_contents(__DIR__.'/attachment.bin',$bytes);
    if ($written!==strlen($bytes)) {throw new RuntimeException('Файл записан не полностью.');}
    $empty=p179Read($db,'SELECT payload FROM '.$t.' WHERE id=2')[0]['payload'];
    $missing=p179Read($db,'SELECT payload FROM '.$t.' WHERE id=3')[0]['payload'];
    $digest=hash_file('sha256',__DIR__.'/attachment.bin');
    $checks=[
        'selected_body_exact'=>$bytes===$payload && bin2hex($bytes)==='50444f5f44424c494200ff100a',
        'file_exact'=>$written===strlen($payload) && $digest===hash('sha256',$payload),
        'catalog_bounded'=>count($catalog)===3 && (int)$catalog[0]['byte_count']===strlen($payload),
        'empty_not_null'=>(int)$catalog[1]['is_missing']===0 && (int)$catalog[1]['byte_count']===0 && $empty==='',
        'sql_null_missing'=>(int)$catalog[2]['is_missing']===1 && $catalog[2]['byte_count']===null && $missing===null,
        'source_binary_kept'=>str_contains($bytes,"\x00") && str_contains($bytes,"\xff"),
    ];
    $rows=[['attachment.bin',strlen($bytes).' байт; NUL и FF сохранены'],
        ['Полученные байты',strtoupper(bin2hex($bytes))],['SHA256',$digest],
        ['empty.bin','Файл есть: 0 байт; IS NULL = 0'],
        ['missing.bin','Вложения нет: IS NULL = 1'],
        ['Файл','attachment.bin: точная копия выбранного тела']];
    $observations=['catalog'=>$catalog,'selected_id'=>1,'raw_fetch_type'=>get_debug_type($bytes),
        'payload_hex'=>strtoupper(bin2hex($bytes)),'bytes'=>strlen($bytes),'sha256'=>$digest,
        'empty_fetch_type'=>get_debug_type($empty),'empty_fetch_bytes'=>is_string($empty)?strlen($empty):null,
        'missing_fetch_type'=>get_debug_type($missing),
        'input_contract'=>'ASCII hex PARAM_STR_CHAR → SQL CONVERT(varbinary(max),?,2)',
        'output_contract'=>'VARBINARY → исходные байты PHP string → file_put_contents; без hex2bin'];

} finally {

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

p179Report('pdo179-attachment-roundtrip','Восстановить выбранное вложение без потери NUL и FF',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
attachment.bin — 13 байт; NUL и FF сохранены
Полученные байты — 50444F5F44424C494200FF100A
SHA256 — bf345993a33529a3be5bf98a84c2281cc32cf3e85262bcda1811353e554d3e96
empty.bin — Файл есть: 0 байт; IS NULL = 0
missing.bin — Вложения нет: IS NULL = 1
Файл — attachment.bin: точная копия выбранного тела

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

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

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

Этот маршрут не тестирует прямой PARAM_LOB с двоичным значением. В PDO_DBLIB эмулируемая подстановка и C-строковый SQL требуют отдельного соглашения для такого ввода; выбранный hex-вход избегает включения NUL в текст команды. При чтении hex2bin не вызывается: raw bytes уже являются содержимым файла. Лимит 1024 байта — осознанная граница урока, а не предел сервера. CLI-файл не является проверенным HTTP-ответом; авторизация и заголовки загрузки потребуют отдельного проекта.

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

Нужно показать список из ста вложений, но скачать только одно. Стоит ли добавить payload в запрос каталога и затем выбрать нужный PHP-массив?

Разбор

Нет. Для списка достаточно id, filename, DATALENGTH и флага IS NULL. У выбранного пользователя сначала проверяются права на документ, затем отдельный SELECT с параметром id получает ровно одно тело. Для большого файла заранее ограничьте допустимый размер и выберите подтверждённый способ передачи; маленькая побайтная проверка не доказывает постоянное потребление памяти.

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

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

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

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

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