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

Дерево руководства
Глава 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.csv | DOC-1042 → UUID 00112233-4455-6677-8899-AABBCCDDEEFF | Сопоставление выбранной партии |
| ledger.csv | 123456789012345.6789; номер 9007199254740993; дробные секунды | Точная ведомость дня |
| attachment.bin | 13 байт с NUL/FF, плюс отдельные empty/NULL | Перенос одного файла |
- SQL-тип: uniqueidentifier / decimal(19,4) / varbinary(max)
- Драйвер: PHP string, int или float; правила FreeTDS и PDO_DBLIB
- Договор приложения: UUID-текст / четыре десятичных знака / исходные байты
- Файл и видимый 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 = 1 | PDO::PARAM_INT | Целое значение для SQL-параметра; не имя колонки |
| title = «Заявка O'Reilly» | PDO::PARAM_STR | PDO::PARAM_STR_NATL | Национальный текст для NVARCHAR; кодировка соединения всё равно должна быть согласована |
| hex = 4100ff | PDO::PARAM_STR | PDO::PARAM_STR_CHAR | ASCII-текст для явного CONVERT(...,2), не raw binary |
| NULL | PDO::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.
- BATCH-42: DOC-1042, DOC-1043, DOC-1044
- SQL WHERE выбирает партию; ORDER BY id задаёт порядок
- UUID: 16 байт в одном режиме → текст в другом; SQL NULL сохраняется
- 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-AABBCCDDEEFFsetAttribute настраивает получение 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 документа |
Самостоятельный проект, исходник и архив
<?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; флаг false | 2026-11-08 12:34:56 | CONVERT(...,126): 2026-11-08T12:34:56.1234567 |
| То же datetime2; флаг true | Nov 8 2026 12:34:56:1234567PM | ISO-колонка осталась той же |
| 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.1234567varchar(40) явно задаёт длину, 126 выбирает ISO-запись, AS occurred_iso — имя выбранной колонки. Это другое представление уже сохранённого значения; оно не переводит момент в UTC. Два применения — стабильная CSV-колонка и сравнение native-вида при смене locale. В FreeTDS locales.conf спецификатор %z означает миллисекунды, поэтому его нельзя объяснять как смещение часового пояса. Конфигурация лаборатории частная; системный locale-файл не меняется.
Обсуждение разных форматов PDO_DBLIB и tsql служит постановкой задачи: одинаковая база ещё не гарантирует одинаковую строку даты в разных клиентах. Нормативные правила взяты из PHP/FreeTDS и SQL Server, а не из старых советов об окружении. Типы, формат и границы явного SQL-преобразования проверяются вместе.
Сохранить четыре десятичных знака и время проводок
Система расчётов принимает суммы с четырьмя знаками после точки и большие номера документов. Испорченная последняя цифра меняет сумму или связь с документом. Проект формирует ведомость одного дня: три строки, точный SQL-итог, дробные секунды и отдельно сохранённое смещение времени.
- PAY-701: строка 123456789012345.6789 → DECIMAL(19,4)
- WHERE 2026-11-08 ≤ occurred_at < 2026-11-09 выбирает 3 строки
- STRINGIFY_FETCHES и SQL ISO-текст сохраняют цифры; SUM выполняется в SQL
- 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.6789ATTR_STRINGIFY_FETCHES — настройка PDO, которая в этом драйвере участвует в числовом преобразовании. true выбирает текст; это не команда SQL и не округление. fetchColumn возвращает одну колонку первой строки. is_string проверяет договор перед экспортом. Четыре цифры после точки принадлежат DECIMAL(19,4); PHP не переводит их в float ни при записи, ни при суммировании.
Что получится после запуска
| Наблюдение | Результат |
|---|---|
| Период | 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 проводки |
Самостоятельный проект, исходник и архив
<?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-параметра проекта выбран именно такой текстовый вход; при чтении ожидаются исходные байты. Эти направления нельзя перепутать.
Почему не достаточно написать 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.bin | IS NULL = 0; DATALENGTH = 13 | Файл можно скачать; показаны размер и SHA |
| empty.bin | IS NULL = 0; DATALENGTH = 0 | Вложение есть, намеренно пустое |
| missing.bin | IS 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.
- Собственные 13 байт: PDO_DBLIB, NUL, FF, 10, перевод строки
- bin2hex → ASCII-параметр → CONVERT(...,2) → VARBINARY(MAX)
- WHERE id=1 выбирает одно тело; список остальных файлов содержит только метаданные
- 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.bin | 13 байт; NUL и FF сохранены |
| Полученные байты | 50444F5F44424C494200FF100A |
| SHA256 | bf345993a33529a3be5bf98a84c2281cc32cf3e85262bcda1811353e554d3e96 |
| empty.bin | Файл есть: 0 байт; IS NULL = 0 |
| missing.bin | Вложения нет: IS NULL = 1 |
| Файл | attachment.bin: точная копия выбранного тела |
Самостоятельный проект, исходник и архив
<?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 получает ровно одно тело. Для большого файла заранее ограничьте допустимый размер и выберите подтверждённый способ передачи; маленькая побайтная проверка не доказывает постоянное потребление памяти.
Источники и обсуждения
- PHP Manual: Pdo\Dblib
- PHP Manual: подготовка PDO
- PHP Manual: константы PDO
- PHP Manual: метаданные выбранной колонки
- PHP 8.5.11 PDO_DBLIB: соединение и параметры
- PHP 8.5.11 PDO_DBLIB: типы результата
- FreeTDS: формат даты locales.conf
- FreeTDS: настройки передачи
- SQL Server: DECIMAL/NUMERIC
- SQL Server: CAST/CONVERT
Код и руководство с собой
Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.
Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.
Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.


