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

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

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

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

Глава 305 · После первой параметризованной выборки PDO_PGSQL

PDO_PGSQL: загрузить поставку, отменить ошибочный файл и сверить выгрузку

Три законченных процесса COPY: точная поставка из массива/Generator, отмена ошибочного файла и сверка выбранного экспорта.

Поставка, квитанция и файл для партнёра

Магазин получил поставку из четырёх строк. Нужно загрузить её без ручного INSERT для каждой записи, отказаться от всего файла при неверном остатке и передать партнёру выбранные товары. На выходе этой главы будут три открываемых результата: supplier.tsv с точными значениями, import-receipt.csv с изменениями остатков и selected.tsv с проверенным набором для партнёра.

PDO позволяет PHP отправить запрос в PostgreSQL и получить нужные строки. Драйвер PDO_PGSQL использует клиентскую библиотеку libpq. Одна строка таблицы, файл в PHP и отдельный объект базы — разные сущности; ниже показано, как сохранить их связь.

Проекты запускаются собственным PHP 8.5.11 с PDO_PGSQL в отдельной учебной базе PostgreSQL. Начало SQL и PDO объясняет таблицы и запросы, а подключение PDO_PGSQL — подготовку окружения и параметры. Сначала рассмотрите маленький вызов, затем скачайте проект с созданием данных и уборкой.

Один договор на входе и выходе
  1. Поставщик: SKU, название, остаток, заметка
  2. Кодировщик COPY text: понятные значения → строки протокола
  3. PostgreSQL: типы колонок, CHECK и общая транзакция
  4. SQL выбирает нужные данные → COPY возвращает записи
  5. HTML показывает товары; TSV/CSV можно открыть и передать дальше

COPY — команда массового обмена строками с PostgreSQL. Четыре метода PDO_PGSQL дают ей вход из PHP-массива/итератора или файла и выход в массив или файл. Они не создают структуру таблицы и не угадывают смысл колонок. Для одного изменения карточки привычный prepare и execute обычно проще; для одинаковых записей поставки удобно один раз задать договор передачи.

Что передаёмВ нашем примереЧем это не является
PHP-массив значений[1, "SUP-41", "Кабель", 3, null]Ещё не готовая запись COPY
Строка COPY text1[TAB]SUP-41[TAB]Кабель[TAB]3[TAB]\N[LF]Не SQL INSERT и не строка CSV с кавычками
Строка таблицыordinal=1, sku=SUP-41, qty=3, note=SQL NULLНе объект PHP и не содержимое файла
Полезный артефактsupplier.tsv или квитанция import-receipt.csvУ каждого файла явно свой формат

Сначала разберём одну запись. В PHP знак $ начинает имя переменной; = сохраняет значение, [] создаёт массив, запятая разделяет его элементы. Строка в двойных кавычках понимает \t как табуляцию и \n как перенос. \\ даёт один обратный слеш. У COPY есть свой второй уровень правил: поэтому для произвольных названий ниже используется отдельный кодировщик.

$line = "1\tSUP-41\tКабель\t3\t\\N\n";
// В bytes записи: пять полей, четыре табуляции и один завершающий LF.
$accepted = $db->copyFromArray('supplier_items', [$line],
    fields: 'ordinal,sku,title,qty,note');

Если supplier_items уже создана с указанными колонками, после успешного вызова в ней появится товар SUP-41 с количеством3 и отсутствующей заметкой. $accepted содержит true, а сам товар проверяется отдельным SELECT. Стрелка -> обращается к методу соединения $db; круглые скобки содержат аргументы, fields: называет необязательный аргумент, точка с запятой завершает инструкцию. Полный проект ниже самостоятельно создаёт таблицу, выполняет чтение и показывает результат.

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

Значения PHP и записи COPY text

Договор файла должен отвечать на три вопроса: где заканчивается колонка, где заканчивается запись и как отличить отсутствие значения от пустого текста. В проектах это COPY text: разделитель TAB, конец записи LF, маркер SQL NULL — обратный слеш и N. Заголовка нет. Формат сохраняется одинаковым в кодировщике, методе импорта и проверке выгрузки.

Значение приложенияЧто находится внутри одного поля COPY textПосле чтения SQL
PHP null\NSQL NULL
Пустая строкаНоль символов между разделителямиПустая строка
Буквальные два символа \N\\NТекст \N
Название с табуляциейБирка\tсиняяТабуляция внутри названия
Две строки заметкиСтрока 1\nСтрока 2Перенос внутри одного значения
Папка\архивПапка\\архивОдин обратный слеш

Кодировщик проекта сохраняет NULL отдельно, затем заменяет управляющие символы и обратный слеш. Апостроф в O'Reilly здесь не завершает SQL-литерал: данные отправляются как COPY-записи. Это не повод передавать непроверенные имена таблиц: синтаксис команды и содержимое записи идут разными путями. В UTF-8 обычные русские символы остаются текстом; NUL отклоняется до COPY.

function copyField(string|int|null $value): string {
    if ($value === null) {return '\\N';}
    return strtr((string)$value, [
        "\\" => "\\\\", "\t" => "\\t", "\n" => "\\n", "\r" => "\\r",
    ]);
}
$line = implode("\t", array_map('copyField',
    [1, 'SUP-41', "Кабель O'Reilly", 3, null])) . "\n";

Двоеточие после списка аргументов задаёт возвращаемый тип string; знак | разрешает входную строку, целое число или null. array_map применяет функцию к каждому полю, implode соединяет результаты табуляцией. Точка соединяет две PHP-строки. В самостоятельном проекте кодировщик дополнительно обрабатывает управляющие символы и проверяет NUL. Это узкий кодировщик наших типов, а не универсальная сериализация массивов, дат, bytea или произвольных объектов.

Расширение .tsv обозначает здесь файл обмена с правилами COPY text. Чтобы получить обычный CSV, нельзя просто заменить TAB запятой: CSV иначе обрабатывает кавычки и переносы. Для входного CSV сначала разбирают записи CSV-парсером, проверяют число и назначение колонок, затем кодируют значения в выбранный COPY-формат. Обсуждение реального отказа импорта из-за переноса строки показывает именно эту путаницу; ответы с разбиением CSV через explode не используются в наших проектах.

Не заменяйте PHP null строкой «NULL» или «None». Вопрос о NULL при COPY из Python служит примером потребности сохранить смысл значения; правила другого клиентского API сюда не переносятся. На нашей стороне копируемый маркер и PHP-синтаксис заданы явно, а результат проверяется SELECT.

У nullAs есть дополнительный уровень экранирования: значение аргумента по умолчанию состоит из трёх байтов — два обратных слеша и N. Обёртка вставляет его в SQL escape-литерал, а маркер внутри записи состоит уже из одного слеша и N. В подготовке для PHP8.5.11 переданный вручную аргумент с одним обратным слешем ошибочно задал маркер N. Отдельная проверка стандартного аргумента сохранила четыре различных значения: SQL NULL, пустой текст, буквальное \N и N. В наших вызовах nullAs опущен, а fields передан по имени.

copyFromArray: массив и последовательный источник

Когда приложение уже получило товары как значения PHP, Pdo\Pgsql::copyFromArray передаёт подготовленные строковые записи в таблицу. Первый полезный вариант — маленькая поставка целиком в массиве. Второй — последовательный источник записей: Generator отдаёт следующую строку по запросу метода, не требуя отдельного массива готовых COPY-строк.

public function Pdo\Pgsql::copyFromArray(
    string $tableName,
    array|Traversable $rows,
    string $separator = "\t",
    string $nullAs = "\\\\N",
    ?string $fields = null
): bool

Современный метод доступен с PHP8.4; Traversable в rows добавлен в PHP8.5. rows — набор строк с разделёнными полями, не массив ассоциативных записей. true обозначает успех команды, false — отказ в режиме без исключения. С ERRMODE_EXCEPTION SQL-отказ попадает в PDOException; неверный PHP-тип аргумента может дать TypeError раньше SQL. Вернувшийся true не заменяет commit явной транзакции и не сообщает число принятых строк.

АргументСмысл и договор проекта
tableName: stringУже существующая таблица. В проектах — проверенное имя своей таблицы, отдельно заключённое в кавычки вместе со схемой public. Это не значение SQL-параметра.
separator: string = "\t"Один однобайтовый разделитель. Проекты оставляют TAB. Это не переключатель FORMAT CSV.
nullAs: string = "\\\\N"Синтаксис PHP по умолчанию содержит экранирование для конструкции команды; поле данных содержит маркер \N. Проекты не меняют default и не берут маркер из запроса пользователя.
fields: ?string = nullФиксированный SQL-список колонок через запятую, в порядке полей записи. null оставляет стандартный набор колонок COPY; явный список устойчивее к добавлению служебной колонки.
function nextStockRows(): Generator {
    yield "3\tSUP-43\tПапка\\\\архив\t2\t\\\\N\n";
    yield "4\tSUP-44\tИнструкция\t1\t\\N\n";
}
$db->copyFromArray('supplier_items', nextStockRows(),
    fields: 'ordinal,sku,title,qty,note');

yield выдаёт одну строку и приостанавливает функцию до следующего шага. В первой записи выше заметка — буквальное \N, во второй — SQL NULL. На настоящих данных удобнее вызывать copyLine180 для каждого набора значений, как в проекте поставки. Там два вызова COPY объединены одной транзакцией и проверены обычным SELECT. Generator не превращает уже загруженный исходный массив в источник с нулевым потреблением памяти.

Второе применение — подготовить строки приёмной таблицы после проверки входного API, а затем выполнить тот же UPDATE JOIN, что в файловом импорте. Меняется только источник строк; проверка SKU и граница commit сохраняются. Не выполняйте запросы к этому же соединению внутри Generator: COPY занимает его протокол. Источник должен завершаться предсказуемо; обработку пользовательских ошибок преобразования выполняйте до передачи или в явно разработанном процессе восстановления.

В закреплённом исходнике8.5.11 обработчик принимает iterable, тогда как современная stub/arginfo ещё описывает array; опубликованный Manual уже указывает array|Traversable. Подготовка данной сборки фактически приняла и массив, и Generator. Это наблюдение исполнения, а не догадка по одному файлу метаданных. Переход от PDO::pgsqlCopyFromArray разобран ниже.

Принять четыре товара без потери табуляции, NULL и обратного слеша

Поставщик передаёт названия и остатки. В названии может быть апостроф, в заметке — перенос строки, а отсутствие заметки отличается от намеренно пустой заметки. Проект сохраняет эти различия и выдаёт supplier.tsv для повторной загрузки. В HTML видны сами товары; обозначения [TAB] и [LF] показывают невидимые символы, а JSON хранит исходные значения.

Путь данных и полученный результат
  1. [1, SUP-41, Кабель, 3, null] — значения приложения
  2. copyLine180: значения → одна строка COPY text
  3. copyFromArray: 2 записи массива; затем Generator выдаёт ещё 2
  4. SELECT ORDER BY ordinal: исходные значения восстановлены
  5. copyToArray → порядок ordinal → supplier.tsv

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

$lines = ["1\tSUP-41\tКабель\t3\t\\N\n"];
$db->copyFromArray('supplier_items', $lines,
    fields: 'ordinal,sku,title,qty,note');

В массиве $lines одна строка протокола: пять полей соединены табуляцией, последний маркер обозначает SQL NULL. Стрелка -> вызывает метод готового соединения; fields: задаёт именованный аргумент со списком колонок в том же порядке. supplier_items здесь — заранее созданная таблица. Полный проект создаёт собственное случайное имя. Для обычных значений удобнее кодировщик, чем ручной подсчёт обратных слешей.

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

НаблюдениеРезультат
ПоставкаSUP-41: Кабель O'Reilly — 3; SUP-42: Бирка [TAB] синяя — 0
ПродолжениеSUP-43: Папка\архив — 2; SUP-44: Инструкция [LF] склад — 1
ЗаметкиSUP-41: SQL NULL; SUP-42: пустая строка; SUP-43: буквальное \N
Вход2 строки массива + 2 строки Generator
Файлsupplier.tsv: 4 записи COPY text, UTF-8, без заголовка
Самостоятельный проект, исходник и архив
Принять четыре товара без потери табуляции, NULL и обратного слешаСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

// Кодировщик именно COPY text с табуляцией; это не CSV-кодировщик.
function copyCell180(string|int|null $value): string {
    if ($value===null) {return '\\N';}
    $text=(string)$value;
    if (str_contains($text,"\0")) {throw new InvalidArgumentException('NUL не допускается в PostgreSQL text.');}
    return strtr($text,["\\"=>"\\\\","\t"=>"\\t","\n"=>"\\n","\r"=>"\\r","\x08"=>"\\b","\x0c"=>"\\f","\x0b"=>"\\v"]);
}
function copyLine180(array $record): string {
    return implode("\t",array_map('copyCell180',$record))."\n";
}
function saveBytes180(string $name,string $bytes): void {
    if(file_put_contents(__DIR__.'/'.$name,$bytes)!==strlen($bytes)) {
        throw new RuntimeException('Файл записан не полностью.');
    }
}
// Только малый, уже отобранный SQL набор с целым уникальным ordinal в первом поле.
function orderedCopy180(array $lines,int $limit): array {
    if(count($lines)>$limit){throw new RuntimeException('Превышен предел выбранного набора.');}
    $indexed=[];
    foreach($lines as $line){
        if(!is_string($line)||!str_ends_with($line,"\n")){throw new RuntimeException('Ожидалась завершённая COPY-запись.');}
        $first=explode("\t",$line,2)[0];
        if(!ctype_digit($first)||isset($indexed[(int)$first])){throw new RuntimeException('Неверный ordinal.');}
        $indexed[(int)$first]=$line;
    }
    ksort($indexed,SORT_NUMERIC);
    return array_values($indexed);
}

    $name=p180OwnTable($db,$owned,'supplier',
        '(ordinal integer PRIMARY KEY,sku text NOT NULL UNIQUE,title text NOT NULL,qty integer NOT NULL CHECK(qty>=0),note text NULL)');
    $target=p180Qualified($name);
    $records=[
        [1,'SUP-41',"Кабель O'Reilly",3,null],
        [2,'SUP-42',"Бирка\tсиняя",0,''],
        [3,'SUP-43',"Папка\\архив",2,'\\N'],
        [4,'SUP-44',"Инструкция\nсклад",1,"Строка 1\nСтрока 2"],
    ];
    $first=array_map('copyLine180',array_slice($records,0,2));
    $later=(function()use($records): Generator {
        foreach(array_slice($records,2) as $record){yield copyLine180($record);}
    })();
    $db->beginTransaction();
    try {
        $arrayAccepted=$db->copyFromArray($target,$first,fields:'ordinal,sku,title,qty,note');
        $generatorAccepted=$db->copyFromArray($target,$later,fields:'ordinal,sku,title,qty,note');
        if(!$arrayAccepted||!$generatorAccepted){throw new RuntimeException('Поставка не принята.');}
        $db->commit();
    } catch(Throwable $e){p180Rollback($db);throw $e;}
    $actual=p180Read($db,'SELECT ordinal,sku,title,qty,note FROM '.$target.' ORDER BY ordinal');
    $values=array_map(static fn(array $r):array=>[(int)$r['ordinal'],$r['sku'],$r['title'],(int)$r['qty'],$r['note']],$actual);
    $export=$db->copyToArray($target,fields:'ordinal,sku,title,qty,note');
    if($export===false){throw new RuntimeException('Не удалось сверить поставку.');}
    $ordered=orderedCopy180($export,4);
    $expected=implode('',array_map('copyLine180',$records));
    saveBytes180('supplier.tsv',implode('',$ordered));
    $checks=[
        'array_accepted'=>$arrayAccepted===true,
        'generator_accepted'=>$generatorAccepted===true,
        'all_values_roundtrip'=>$values===$records,
        'null_empty_literal_distinct'=>$values[0][4]===null&&$values[1][4]===''&&$values[2][4]==='\\N',
        'export_exact'=>file_get_contents(__DIR__.'/supplier.tsv')===$expected,
    ];
    $rows=[
        ['Поставка','SUP-41: Кабель O\'Reilly — 3; SUP-42: Бирка [TAB] синяя — 0'],
        ['Продолжение','SUP-43: Папка\\архив — 2; SUP-44: Инструкция [LF] склад — 1'],
        ['Заметки','SUP-41: SQL NULL; SUP-42: пустая строка; SUP-43: буквальное \\N'],
        ['Вход','2 строки массива + 2 строки Generator'],
        ['Файл','supplier.tsv: 4 записи COPY text, UTF-8, без заголовка'],
    ];
    $observations=['catalog'=>$actual,'copy_records'=>$ordered,'format'=>['delimiter'=>'TAB','null_marker'=>'\\N','newline'=>'LF','header'=>false],
        'artifact'=>['bytes'=>filesize(__DIR__.'/supplier.tsv'),'sha256'=>hash_file('sha256',__DIR__.'/supplier.tsv')]];

} finally {

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

p180Report('pdo180-copy-supplier','Принять четыре товара без потери табуляции, NULL и обратного слеша',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Поставка — SUP-41: Кабель O'Reilly — 3; SUP-42: Бирка [TAB] синяя — 0
Продолжение — SUP-43: Папка\архив — 2; SUP-44: Инструкция [LF] склад — 1
Заметки — SUP-41: SQL NULL; SUP-42: пустая строка; SUP-43: буквальное \N
Вход — 2 строки массива + 2 строки Generator
Файл — supplier.tsv: 4 записи COPY text, UTF-8, без заголовка

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

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

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

Generator поддерживается начиная с PHP8.5. Его тело выдаёт готовые строки и не выполняет SQL на том же соединении во время COPY. В этом учебном наборе исходные четыре записи уже находятся в памяти: пример показывает интерфейс Traversable, а не экономию памяти. TSV не является универсальным CSV: кавычки и запятые не включают режим CSV. Ключи массива не становятся именами SQL-колонок.

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

Добавьте товар SUP-45 с заметкой, состоящей из двух символов: обратный слеш и N. Как не превратить её в SQL NULL?

Разбор

Передайте обычную PHP-строку в copyCell180: он удвоит обратный слеш. Только PHP null получает неэкранированный маркер. Добавьте запись и ожидаемые значения к проверке, увеличьте предел выбранного набора до5. Сама строка «NULL» также остаётся текстом.

copyFromFile: входной файл и общая транзакция

Когда поставка уже записана в локальный файл правильного формата, Pdo\Pgsql::copyFromFile читает его и передаёт записи по соединению. PHP открывает файл на своей стороне: серверу PostgreSQL не требуется видеть этот путь. Это удобно для принятого файла поставщика и для повторной загрузки ранее сохранённого supplier.tsv.

public function Pdo\Pgsql::copyFromFile(
    string $tableName,
    string $filename,
    string $separator = "\t",
    string $nullAs = "\\\\N",
    ?string $fields = null
): bool

Метод существует с PHP8.4. filename — путь читаемого PHP-файла с записями; tableName, separator, nullAs и fields имеют договор предыдущей таблицы. Возвращается true или false. Невозможность открыть входной файл обрабатывается через ERRMODE соединения; при ERRMODE_EXCEPTION это PDOException. Ошибки количества полей, типов, уникальности или CHECK также требуют обработки. Чтение файла само по себе не делает весь последующий бизнес-процесс транзакцией.

$db->beginTransaction();
try {
    $loaded = $db->copyFromFile('supplier_items', __DIR__.'/supplier.tsv',
        fields: 'ordinal,sku,title,qty,note');
    if (!$loaded) {throw new RuntimeException('Файл не загружен.');}
    $db->commit();
} catch (Throwable $e) {
    if ($db->inTransaction()) {$db->rollBack();}
    throw $e;
}

Этот вариант загружает файл из первого проекта в заранее созданную пустую таблицу той же формы. COPY добавляет строки: повторная загрузка тех же ключей не является автоматическим обновлением. Для замены остатков нужен иной договор — приёмная таблица и сопоставление по SKU, показанные в втором проекте. Так различаются перенос архива и применение новых данных к существующему каталогу.

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

Применить файл остатков целиком или оставить прежний каталог

Файл поставщика должен изменить остатки сразу у нескольких товаров. Если одна строка содержит отрицательное количество, нельзя оставить уже обработанную часть каталога. Вторая собственная таблица служит приёмной зоной: сначала COPY и проверка SKU, затем одно обновление каталога. Квитанция показывает конкретные остатки до и после подтверждённой операции.

Путь данных и полученный результат
  1. Каталог: SUP-41=3, SUP-42=0
  2. Первая транзакция: временно99 → COPY(8,-1) → CHECK-ошибка
  3. ROLLBACK: снова3/0, приёмная таблица пуста
  4. Вторая транзакция: COPY(8,4) → проверить SKU → UPDATE JOIN → COMMIT
  5. CSV-квитанция: 3→8 и0→4

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

$db->beginTransaction();
try {
    $db->copyFromFile('incoming_stock', __DIR__.'/incoming.tsv',
        fields: 'sku,qty');
    $db->commit();
} catch (Throwable $e) {
    if ($db->inTransaction()) {$db->rollBack();}
    throw $e;
}

beginTransaction начинает общую операцию; try содержит шаги, catch получает исключение, rollBack отменяет SQL-изменения. __DIR__ — каталог PHP-скрипта, а не каталог сервера PostgreSQL. fields задаёт две колонки файла. В полном проекте commit наступает только после проверки приёмной таблицы и обновления каталога; finally отменяет намеренно ошибочную попытку.

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

НаблюдениеРезультат
Плохой файлSUP-42: -1 отклонён; CHECK, SQLSTATE 23514
После rollbackSUP-41 Кабель: 3; SUP-42 Хомут: 0; входная таблица пуста
Исправленный файлSUP-41 Кабель: 3 → 8; SUP-42 Хомут: 0 → 4
Квитанцияimport-receipt.csv: оба SKU со статусом APPLIED
Самостоятельный проект, исходник и архив
Применить файл остатков целиком или оставить прежний каталогСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

    $catalogName=p180OwnTable($db,$owned,'catalog','(sku text PRIMARY KEY,title text NOT NULL,qty integer NOT NULL CHECK(qty>=0))');
    $stageName=p180OwnTable($db,$owned,'incoming','(sku text PRIMARY KEY,qty integer NOT NULL CHECK(qty>=0))');
    $catalog=p180Qualified($catalogName);$stage=p180Qualified($stageName);
    p180Command($db,'INSERT INTO '.$catalog.' (sku,title,qty) VALUES (?,?,?),(?,?,?)',
        ['SUP-41','Кабель',3,'SUP-42','Хомут',0]);
    $before=p180Read($db,'SELECT sku,title,qty FROM '.$catalog.' ORDER BY sku');
    $failure=null;
    $db->beginTransaction();
    try {
        // Это изменение тоже должно отмениться, если следующая загрузка не пройдёт.
        p180Command($db,'UPDATE '.$catalog.' SET qty=? WHERE sku=?',[99,'SUP-41']);
        $db->copyFromFile($stage,__DIR__.'/incoming-bad.tsv',fields:'sku,qty');
        throw new RuntimeException('Ошибочный файл неожиданно принят.');
    } catch(PDOException $e) {
        $failure=['state'=>(string)($e->errorInfo[0]??$e->getCode()),'message_present'=>$e->getMessage()!==''];
    } finally {p180Rollback($db);}
    $afterFailure=p180Read($db,'SELECT sku,title,qty FROM '.$catalog.' ORDER BY sku');
    $stageAfterFailure=(int)p180Read($db,'SELECT COUNT(*) AS n FROM '.$stage)[0]['n'];
    $db->beginTransaction();
    try {
        $imported=$db->copyFromFile($stage,__DIR__.'/incoming-good.tsv',fields:'sku,qty');
        if(!$imported){throw new RuntimeException('Исправленный файл не загружен.');}
        $unmatched=(int)p180Read($db,'SELECT COUNT(*) AS n FROM '.$stage.' s LEFT JOIN '.$catalog.' c ON c.sku=s.sku WHERE c.sku IS NULL')[0]['n'];
        if($unmatched!==0){throw new RuntimeException('В файле есть неизвестные SKU.');}
        $changed=p180Command($db,'UPDATE '.$catalog.' AS c SET qty=s.qty FROM '.$stage.' AS s WHERE c.sku=s.sku');
        if($changed!==2){throw new RuntimeException('Ожидалось обновление двух товаров.');}
        $db->commit();
    } catch(Throwable $e){p180Rollback($db);throw $e;}
    $after=p180Read($db,'SELECT sku,title,qty FROM '.$catalog.' ORDER BY sku');
    $receipt=[['sku','before_qty','after_qty','status']];
    foreach($after as $i=>$r){$receipt[]=[$r['sku'],(int)$before[$i]['qty'],(int)$r['qty'],'APPLIED'];}
    $file=fopen(__DIR__.'/import-receipt.csv','wb');
    if($file===false){throw new RuntimeException('Нет файла квитанции.');}
    try{foreach($receipt as $r){if(fputcsv($file,$r,';','"','',"\n")===false){throw new RuntimeException('Ошибка записи квитанции.');}}}
    finally{fclose($file);}
    $checks=[
        'bad_file_rejected'=>$failure!==null&&$failure['state']==='23514'&&$failure['message_present'],
        'earlier_change_rolled_back'=>$afterFailure===$before,
        'no_partial_staging'=>$stageAfterFailure===0,
        'corrected_file_accepted'=>$imported===true,
        'catalog_updated'=>(int)$after[0]['qty']===8&&(int)$after[1]['qty']===4,
        'transaction_finished'=>!$db->inTransaction(),
    ];
    $rows=[
        ['Плохой файл','SUP-42: -1 отклонён; CHECK, SQLSTATE 23514'],
        ['После rollback','SUP-41 Кабель: 3; SUP-42 Хомут: 0; входная таблица пуста'],
        ['Исправленный файл','SUP-41 Кабель: 3 → 8; SUP-42 Хомут: 0 → 4'],
        ['Квитанция','import-receipt.csv: оба SKU со статусом APPLIED'],
    ];
    $observations=['before'=>$before,'after_failed_import'=>$afterFailure,'after_commit'=>$after,'failure'=>$failure,
        'staging_rows_after_rollback'=>$stageAfterFailure,'receipt'=>$receipt];

} finally {

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

p180Report('pdo180-copy-file-atomic','Применить файл остатков целиком или оставить прежний каталог',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Плохой файл — SUP-42: -1 отклонён; CHECK, SQLSTATE 23514
После rollback — SUP-41 Кабель: 3; SUP-42 Хомут: 0; входная таблица пуста
Исправленный файл — SUP-41 Кабель: 3 → 8; SUP-42 Хомут: 0 → 4
Квитанция — import-receipt.csv: оба SKU со статусом APPLIED

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

Открыть результат выполнения · Посмотреть данные JSON · Скачать квитанцию import-receipt.csv

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

Файлы входа намеренно малы и принадлежат проекту. В рабочем импорте перед началом ограничивают размер, проверяют формат и выбирают разрешённый путь. SQL-транзакция не удаляет локальный файл и не отменяет запись CSV. Квитанция создаётся после commit: ошибка диска требует восстановления квитанции, а не слепого повторения импорта. Проверка неизвестных SKU не заменяет предметное разрешение на изменение каталога.

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

Как принять файл, где присутствует новый неизвестный SUP-99, не обновив известные товары наполовину?

Разбор

В этом договоре неизвестный SKU — отказ всего пакета: существующая проверка unmatched выбросит исключение до UPDATE и выполнится rollback. Для разрешённого создания новых товаров заранее измените правило импорта и обязательные поля; один остаток недостаточен для новой карточки товара.

copyToArray: сравнить небольшой набор записей

Pdo\Pgsql::copyToArray полезен, когда небольшой выбранный набор требуется сопоставить с входными записями: например, убедиться, что поставка сохранила NULL и специальные символы, или сравнить массив записей с записанным файлом. Результат здесь ближе к строкам файла, чем к результату fetch(PDO::FETCH_ASSOC).

public function Pdo\Pgsql::copyToArray(
    string $tableName,
    string $separator = "\t",
    string $nullAs = "\\\\N",
    ?string $fields = null
): array|false

С PHP8.4. tableName — исходная таблица; fields выбирает экспортируемые колонки и их последовательность. separator и nullAs задают формат записей, как при импорте. Возвращается индексированный массив строк с завершающим переносом; при отказе — false либо PDOException согласно ERRMODE. Для пустой таблицы нормальный результат — пустой массив, поэтому сравнивайте с false строго.

$lines = $db->copyToArray('selected_stock', fields: 'ordinal,sku,qty');
if ($lines === false) {throw new RuntimeException('Нет выгрузки.');}
foreach ($lines as $line) {
    echo htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

Если выбранная таблица содержит ordinal1, SUP-41 и qty8, одна запись имеет вид 1[TAB]SUP-41[TAB]8[LF]. В HTML значение экранировано отдельно от формата базы. Для вывода отдельных ячеек проще обычный параметризованный SELECT; COPY-строка не разбирается автоматически в PHP-поля.

Первое применение в проверке поставки сравнивает обратную выгрузку с собственным кодировщиком. Второе в сверке экспорта проверяет те же записи через файл. Оба набора заранее малы. copyToArray размещает весь возвращаемый массив в памяти; LIMIT и WHERE не являются его аргументами. Выберите нужные данные SQL заранее или используйте другой маршрут чтения. Отдельно задавайте порядок строк — fields отвечает только за колонки. Старое имя — PDO::pgsqlCopyToArray.

copyToFile: получить файл таблицы

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

public function Pdo\Pgsql::copyToFile(
    string $tableName,
    string $filename,
    string $separator = "\t",
    string $nullAs = "\\\\N",
    ?string $fields = null
): bool

С PHP8.4. filename — выходной путь на стороне PHP; остальные аргументы определяют таблицу, разделитель, NULL и список колонок. true означает завершение вызова, false — отказ без исключения. Ошибка открытия или записи файла подчиняется ERRMODE; с ERRMODE_EXCEPTION выбрасывается PDOException. Нужны права PHP на запись. Драйвер8.5.11 открывает файл в режиме wb: существующее содержимое может быть заменено, а при отказе может остаться неполный файл.

$ok = $db->copyToFile('supplier_items', __DIR__.'/supplier-raw.tsv',
    fields: 'ordinal,sku,title,qty,note');
if (!$ok) {throw new RuntimeException('Архив не создан.');}
echo filesize(__DIR__.'/supplier-raw.tsv');

Первое применение — сохранить собственную поставку в формате, который затем принимает copyFromFile. Сумма байтов помогает диагностике, но полезность файла определяется его товарами и договором колонок. Второе — снимок выбранных остатков: результат сравнивается с copyToArray, а пользователь получает файл с явным порядком. В обоих случаях используйте отдельное имя назначения; не направляйте экспорт на единственную копию исходного файла.

Запись файла не откатывается SQL-командой rollback. Для публикации рабочего экспорта обычно сначала пишут отдельный временный файл, проверяют результат и лишь затем заменяют доступный пользователю файл. Такое файловое переключение требует собственного договора; три проекта здесь используют отдельные учебные каталоги и не объявляют межсистемную атомарность. Старое имя — PDO::pgsqlCopyToFile.

Выгрузить выбранный остаток и сверить два пути COPY

Партнёру нужен остаток только своего поставщика. Метод copyToFile не имеет аргументов WHERE и ORDER BY. Поэтому SQL сначала формирует небольшую собственную таблицу снимка. Два пути COPY читают уже выбранные данные; итоговый selected.tsv получает явный порядок по ordinal, и его можно побайтно сверить с путём через массив.

Путь данных и полученный результат
  1. Исходный каталог: 4 товара двух поставщиков
  2. SQL WHERE supplier=S1 AND qty>0 + ORDER BY sku LIMIT2
  3. Собственный снимок: ordinal1=Кабель8, ordinal2=Лента5
  4. copyToArray и copyToFile: две формы передачи тех же записей
  5. Сортировка двух записей по ordinal → selected.tsv; каталог уже может измениться

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

$records = $db->copyToArray('selected_stock', fields: 'ordinal,sku,qty');
$written = $db->copyToFile('selected_stock', __DIR__.'/selected-raw.tsv',
    fields: 'ordinal,sku,qty');

$records содержит строки COPY text, а не ассоциативные записи fetch. $written — признак записи файла, не число строк и не его содержимое. Оба вызова читают заранее подготовленную selected_stock. fields явно выбирает порядок колонок, но не сортирует строки. В полном проекте SQL отбирает два товара, ordinal закрепляет требуемый порядок, а нормализация применяется только к этому маленькому набору.

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

НаблюдениеРезультат
УсловиеПоставщик S1, qty > 0, не более2 товаров по SKU
Снимок1: SUP-41 Кабель — 8; 2: SUP-44 Лента — 5
Не вошлиSUP-42: нулевой остаток; SUP-43: другой поставщик
После изменения каталогаSUP-41 в каталоге10, в выгрузке выбранного снимка8
СверкаcopyToArray и copyToFile содержат те же2 записи; selected.tsv упорядочен по ordinal
Самостоятельный проект, исходник и архив
Выгрузить выбранный остаток и сверить два пути COPYСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/lab.php';
$db=null;$owned=$removed=$rows=$checks=$observations=[];

try {
    $config=p180Config();$db=p180Connect($config);$runtime=p180Runtime($db);

// Кодировщик именно COPY text с табуляцией; это не CSV-кодировщик.
function copyCell180(string|int|null $value): string {
    if ($value===null) {return '\\N';}
    $text=(string)$value;
    if (str_contains($text,"\0")) {throw new InvalidArgumentException('NUL не допускается в PostgreSQL text.');}
    return strtr($text,["\\"=>"\\\\","\t"=>"\\t","\n"=>"\\n","\r"=>"\\r","\x08"=>"\\b","\x0c"=>"\\f","\x0b"=>"\\v"]);
}
function copyLine180(array $record): string {
    return implode("\t",array_map('copyCell180',$record))."\n";
}
function saveBytes180(string $name,string $bytes): void {
    if(file_put_contents(__DIR__.'/'.$name,$bytes)!==strlen($bytes)) {
        throw new RuntimeException('Файл записан не полностью.');
    }
}
// Только малый, уже отобранный SQL набор с целым уникальным ordinal в первом поле.
function orderedCopy180(array $lines,int $limit): array {
    if(count($lines)>$limit){throw new RuntimeException('Превышен предел выбранного набора.');}
    $indexed=[];
    foreach($lines as $line){
        if(!is_string($line)||!str_ends_with($line,"\n")){throw new RuntimeException('Ожидалась завершённая COPY-запись.');}
        $first=explode("\t",$line,2)[0];
        if(!ctype_digit($first)||isset($indexed[(int)$first])){throw new RuntimeException('Неверный ordinal.');}
        $indexed[(int)$first]=$line;
    }
    ksort($indexed,SORT_NUMERIC);
    return array_values($indexed);
}

    $sourceName=p180OwnTable($db,$owned,'stock_source','(sku text PRIMARY KEY,supplier text NOT NULL,title text NOT NULL,qty integer NOT NULL)');
    $snapshotName=p180OwnTable($db,$owned,'stock_snapshot','(ordinal integer PRIMARY KEY,sku text NOT NULL,title text NOT NULL,qty integer NOT NULL)');
    $source=p180Qualified($sourceName);$snapshot=p180Qualified($snapshotName);
    foreach([['SUP-41','S1','Кабель',8],['SUP-42','S1','Хомут',0],['SUP-43','S2','Верёвка',9],['SUP-44','S1','Лента',5]] as $r){
        p180Command($db,'INSERT INTO '.$source.' (sku,supplier,title,qty) VALUES (?,?,?,?)',$r);
    }
    $empty=$db->copyToArray($snapshot,fields:'ordinal,sku,title,qty');
    p180Command($db,'INSERT INTO '.$snapshot.' (ordinal,sku,title,qty) SELECT CAST(row_number() OVER (ORDER BY sku) AS integer),sku,title,qty FROM '.$source.' WHERE supplier=? AND qty>0 ORDER BY sku LIMIT 2',['S1']);
    // Меняется исходный каталог, а выбранный снимок уже зафиксировал8.
    p180Command($db,'UPDATE '.$source.' SET qty=? WHERE sku=?',[10,'SUP-41']);
    $selected=p180Read($db,'SELECT ordinal,sku,title,qty FROM '.$snapshot.' ORDER BY ordinal');
    $fromArray=$db->copyToArray($snapshot,fields:'ordinal,sku,title,qty');
    if($fromArray===false){throw new RuntimeException('COPY в массив не завершён.');}
    $toFile=$db->copyToFile($snapshot,__DIR__.'/copy-raw.tsv',fields:'ordinal,sku,title,qty');
    if(!$toFile){throw new RuntimeException('COPY в файл не завершён.');}
    $raw=file(__DIR__.'/copy-raw.tsv');
    if($raw===false){throw new RuntimeException('Нет экспортированного файла.');}
    $a=orderedCopy180($fromArray,2);$b=orderedCopy180($raw,2);
    saveBytes180('selected.tsv',implode('',$b));
    $fresh=p180Read($db,'SELECT qty FROM '.$source.' WHERE sku=?',['SUP-41'])[0]['qty'];
    $checks=[
        'empty_array'=>$empty===[],
        'selected_only'=>count($selected)===2&&$selected[0]['sku']==='SUP-41'&&$selected[1]['sku']==='SUP-44',
        'snapshot_values'=>(int)$selected[0]['qty']===8&&(int)$selected[1]['qty']===5&&(int)$fresh===10,
        'array_file_agree'=>$a===$b&&$toFile===true,
        'artifact_matches'=>file_get_contents(__DIR__.'/selected.tsv')===implode('',$a),
    ];
    $rows=[
        ['Условие','Поставщик S1, qty > 0, не более2 товаров по SKU'],
        ['Снимок','1: SUP-41 Кабель — 8; 2: SUP-44 Лента — 5'],
        ['Не вошли','SUP-42: нулевой остаток; SUP-43: другой поставщик'],
        ['После изменения каталога','SUP-41 в каталоге10, в выгрузке выбранного снимка8'],
        ['Сверка','copyToArray и copyToFile содержат те же2 записи; selected.tsv упорядочен по ordinal'],
    ];
    $observations=['selected'=>$selected,'fresh_source_qty'=>(int)$fresh,'array_records'=>$a,'file_records'=>$b,
        'order_contract'=>'SQL selection + explicit ordinal; bounded normalization after COPY',
        'artifact'=>['bytes'=>filesize(__DIR__.'/selected.tsv'),'sha256'=>hash_file('sha256',__DIR__.'/selected.tsv')]];

} finally {

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

p180Report('pdo180-copy-reconciliation','Выгрузить выбранный остаток и сверить два пути COPY',$rows,$checks,$observations,$runtime,$owned,$removed);
Ожидаемый результат
Условие — Поставщик S1, qty > 0, не более2 товаров по SKU
Снимок — 1: SUP-41 Кабель — 8; 2: SUP-44 Лента — 5
Не вошли — SUP-42: нулевой остаток; SUP-43: другой поставщик
После изменения каталога — SUP-41 в каталоге10, в выгрузке выбранного снимка8
Сверка — copyToArray и copyToFile содержат те же2 записи; selected.tsv упорядочен по ordinal

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

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

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

COPY всей таблицы не обещает порядок вставки или первичного ключа. Здесь сравниваются нормализованные записи, а исходный copy-raw.tsv не объявлен отсортированным. Таблица снимка — сохранённый результат одной выборки, не долгоживущая гарантия неизменности рабочего каталога. Для большой выгрузки не переносите эту сортировку целиком в PHP: нужен отдельный, проверенный маршрут упорядоченного серверного COPY или последовательное чтение SELECT ORDER BY.

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

Партнёр просит три позиции, включая товары с нулевым остатком. Какие две части договора меняются?

Разбор

Уберите условие qty>0, измените SQL LIMIT на3 и предел orderedCopy180 на3; ожидаемый файл теперь включает SUP-42. Отбор остаётся в SQL. Нельзя просто увеличить PHP-предел: он проверяет размер уже выбранного набора, а не добавляет отсутствующие товары.

Отбор, порядок, имена и отказ передачи

У COPY-методов нет параметров для WHERE, ORDER BY, FORMAT CSV, HEADER или произвольной команды. PostgreSQL как сервер умеет больше, но это не расширяет автоматически контракт конкретного PHP-метода. В этой главе tableName всегда остаётся проверенным именем таблицы, fields — фиксированным списком колонок.

Отбор и порядок — разные решения
  1. WHERE supplier=S1 AND qty>0: какие товары нужны
  2. ORDER BY sku LIMIT2: какие два товара входят в снимок
  3. ordinal: номер выбранного товара, сохранённый в снимке
  4. COPY: перенос записей снимка без обещания порядка
  5. Нормализация двух строк по ordinal: точный selected.tsv

В PHP8.5.11 обёртка собирает текст COPY из tableName, fields и параметров формата. Она не вызывает escapeIdentifier для этих аргументов и не связывает их как значения prepare. Поэтому имена берутся из реестра своих объектов, кавычки применяются к имени схемы и таблицы отдельно, а формат не зависит от URL или заголовка внешнего файла. Параметры и идентификаторы PDO_PGSQL объясняются в соседней главе.

Выборка без ORDER BY не гарантирует порядок. Важно различать физическую очередность записей и предметный номер: первичный ключ ordinal не заставляет COPY сортировать вывод. Проект сортирует только две уже выбранные строки; он не читает весь рабочий каталог ради фильтрации в PHP. Сравнение сырых файлов без договорённости о порядке могло бы показать разные хеши у одинаковых наборов.

Отказ или ограничениеДействие приложения
Неверное число полей или CHECKСохранить безопасную диагностику, отменить явную транзакцию, исправить вход
Ошибка открытия файлаПроверить разрешённый путь и права PHP; не путать с доступом сервера к его файлам
Ошибка записи после части выводаНе публиковать частичный файл; SQL rollback не удалит его
Неправильный PHP-типИсправить контракт до COPY; TypeError не является отказом PostgreSQL
Повторный файл с теми же ключамиЗаранее выбрать правило дубликатов; COPY не является UPSERT
Рост объёмаОграничить вход, планировать staging и место на диске; измерять конкретный маршрут

ERRMODE_SILENT оставляет код ошибки для явной проверки, ERRMODE_WARNING дополнительно выдаёт предупреждение, ERRMODE_EXCEPTION передаёт PDOException. Здесь выбран последний режим, но ожидаемый отказ всё равно перехватывается локально и показывается понятным результатом. Полный текст SQL-ошибки может включать значения и внутренние имена, поэтому публичный отчёт сохраняет SQLSTATE и предметное объяснение, а не весь текст исключения.

COPY объединяет передачу одинаковых записей, но эта глава не измеряет скорость и не обещает превосходство для любого объёма. Важнее сохранённый смысл данных и проверенная граница завершения. Если нужна выдача отдельного двоичного вложения или уведомление после commit, следующий шаг — вложения и события PDO_PGSQL.

Четыре старых имени PDO и переход на Pdo\Pgsql

В существующем PHP-проекте могут встретиться методы PDO с префиксом pgsql. Они доступны с PHP5.3.3 и объявлены устаревшими с PHP8.5. Современные имена принадлежат Pdo\Pgsql, появившемуся в PHP8.4. При сопровождении сначала определяют версию PHP и способ создания соединения, затем заменяют имя вызова; формат данных, права и транзакцию сохраняют. Старые имена не являются новым механизмом обмена.

#[\Deprecated]
public function PDO::pgsqlCopyFromArray(
    string $tableName, array|Traversable $rows,
    string $separator = "\t", string $nullAs = "\\\\N", ?string $fields = null
): bool

#[\Deprecated]
public function PDO::pgsqlCopyFromFile(
    string $tableName, string $filename,
    string $separator = "\t", string $nullAs = "\\\\N", ?string $fields = null
): bool

#[\Deprecated]
public function PDO::pgsqlCopyToArray(
    string $tableName, string $separator = "\t",
    string $nullAs = "\\\\N", ?string $fields = null
): array|false

#[\Deprecated]
public function PDO::pgsqlCopyToFile(
    string $tableName, string $filename,
    string $separator = "\t", string $nullAs = "\\\\N", ?string $fields = null
): bool

Это справочная запись сигнатур, а не программа для выполнения. #[Deprecated] обозначает устаревание. Для старого pgsqlCopyFromArray тип Traversable также добавлен только в8.5; до этого rows был массивом строк. У file-методов filename остаётся PHP-путём, у toArray результат остаётся массивом записей, у остальных — bool. Все общие аргументы, правила NULL, отказов и ERRMODE совпадают с соответствующим современным методом выше.

// Было в существующем коде:
$oldPdo->pgsqlCopyFromFile('incoming_stock', $approvedFile,
    fields: 'sku,qty');
// После перевода создания соединения на Pdo\Pgsql:
$pgsql->copyFromFile('incoming_stock', $approvedFile,
    fields: 'sku,qty');

Пара строк показывает замену имени, выполнять оба импорта подряд не нужно. Второй пример сопровождения — заменить pgsqlCopyToFile на copyToFile для прежнего файла выбранной таблицы, сохранив проверку результата и порядок колонок. Новые проекты используют современные методы; старые вызовы здесь не объявлены отдельно исполненными. Канонические карточки и прежние учебные определения остаются доступными по ссылкам.

Упражнение: в старом импорте разработчик заменил имя метода, а separator изменил с табуляции на запятую, оставив файл прежним. Почему перенос версии не решит отказ? Разбор: это два разных изменения. Для миграции API сохраняют договор формата; изменение разделителя требует заново сформировать файл и проверить его поля. Исправьте separator либо преобразуйте записи отдельным шагом, затем проверьте те же товары через SELECT.

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

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

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

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

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