Василий Кривопляс
← На главную

Архитектура · PHP · SQL · PDOdb

PDOdb: один Query Builder, шесть диалектов SQL

Один Query Builder для MySQL, MariaDB, PostgreSQL, SQLite, MSSQL и Oracle. За общим вызовом — разный SQL, особенности драйверов и решения о том, что выполнить нативно, что выразить иначе, а где явно остановиться.

Что именно должен переносить общий API

В основе PDOdb — практическая задача: описать операцию доступа к данным один раз и выполнять её на поддерживаемых СУБД. Это полезно библиотеке, инструменту или продукту, который устанавливается в разные окружения. Необязательно регулярно менять базу работающего приложения, чтобы переносимость имела смысл.

PDO унифицирует подключение и выполнение запросов, но SQL остаётся разным. Отличаются ограничение выборки, UPSERT, JSON-выражения, получение идентификатора после вставки. Если эти различия расползаются по прикладному коду, каждый новый диалект умножает места, которые нужно изменить и проверить.

В PDOdb эта работа сосредоточена вокруг диалектов. Общий Query Builder поддерживает MySQL, MariaDB, PostgreSQL, SQLite, Microsoft SQL Server и Oracle. Прикладной код может оставаться общим, если использует совместимый набор операций и учитывает возможности выбранных версий баз. Сама замена DSN не делает любой SQL переносимым.

Диалект — часть выполнения запроса

Query Builder собирает описание операции и её параметры. Диалект задаёт правила представления идентификаторов, SQL-фрагментов и доступных возможностей. ExecutionEngine и соединение отвечают за подготовку и выполнение. Благодаря этому проверку конкретного синтаксиса можно отделить от проверки результата на живой базе.

Внутри диалекта обязанности тоже разделены. DML-компонент строит операции записи, DDL — изменения схемы, SqlFormatter оформляет конструкции запроса, FeatureSupport сообщает о поддержке отдельных возможностей. Части реализации можно разделять и переиспользовать, сохраняя один контракт для Query Builder.

  1. Query BuilderВыборка, условия, сортировка, запись; параметры передаются отдельно от SQL
  2. native emulate refuse
    Диалект и его компонентыDML, DDL, форматирование SQL, JSON, возможности конкретной СУБД
  3. ExecutionEngine → Connection → PDOПодготовка, выполнение, получение результата и обработка ошибок
Query Builder описывает операцию. Диалект определяет её SQL-форму и поддержку возможностей; исполнение работает с SQL и параметрами через соединение. Refuse останавливает неподдерживаемую операцию до выполнения SQL.

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

Одна выборка, разные формы SQL

Для обычной выборки прикладному коду нужны таблица, поля, фильтр, порядок и размер страницы. В следующем фрагменте объект $db уже подключён к выбранной СУБД; $accountId получен и проверен вызывающим кодом.

PHP 8.4+ · общий вызов Query Builder
$rows = $db->find()
    ->from('events')
    ->select(['id', 'value'])
    ->where('account_id', $accountId)
    ->orderBy('id', 'ASC')
    ->limit(10)
    ->offset(20)
    ->get();

Форма ограничения выборки относится к диалекту. В MySQL, MariaDB, PostgreSQL и SQLite используются LIMIT/OFFSET; SQL Server и Oracle поддерживают OFFSET … FETCH. В SQL Server для этого варианта требуется ORDER BY. В прикладном вызове он задан явно: библиотека не должна угадывать, какой порядок нужен пользователю.

Для устойчивой пагинации порядок должен однозначно определять положение строки. Здесь это id. Если сортировка идёт по неуникальному полю, нужен дополнительный ключ; корректный синтаксис LIMIT сам по себе не устраняет пропуски и повторы при изменении данных.

Что остаётся внутри диалекта

Ниже показана форма SQL для страницы из десяти строк с пропуском первых двадцати. Кавычки идентификаторов и фильтр опущены, чтобы оставить видимым именно различие пагинации.

SQL · форма ограничения выборки
-- MySQL / MariaDB / PostgreSQL / SQLite
SELECT id FROM events ORDER BY id LIMIT 10 OFFSET 20

-- MSSQL / Oracle
SELECT id FROM events ORDER BY id
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY

Quoting решает другую задачу: events превращается в `events`, "events" или [events] в зависимости от диалекта. Oracle в текущем соглашении PDOdb приводит такой идентификатор к верхнему регистру и получает "EVENTS". Это влияет и на ожидания от имён полей результата.

Значения передаются параметрами. Имена таблиц, колонок и направление сортировки нельзя сделать безопасными простым placeholder: если их выбирает пользователь, прикладной код должен ограничить выбор разрешённым набором. Raw-выражение требует такого же внимания, как SQL, написанный вручную.

UPSERT: общий смысл, явные ограничения

Обновить строку при конфликте — ещё одна операция, которую хочется описать один раз. Для MySQL/MariaDB PDOdb формирует ON DUPLICATE KEY UPDATE, для PostgreSQL и SQLite — ON CONFLICT с обращением к вставляемым значениям через EXCLUDED.

В примере id — первичный ключ, а при конфликте нужно заменить value. Именно для такого контракта один вызов выражает одно намерение.

Диалект Поведение этого вызова
MySQL / MariaDB ON DUPLICATE KEY UPDATE
PostgreSQL / SQLite ON CONFLICT (id) DO UPDATE с EXCLUDED
MSSQL / Oracle Явный отказ для этого способа UPSERT; отдельный merge()
PHP 8.4+ · вставка или обновление по первичному ключу
$db->find()
    ->table('events')
    ->onDuplicate(['value'])
    ->insert(['id' => 42, 'value' => 15]);

В MSSQL и Oracle этот путь вставки явно отклоняется. Для соответствующей операции есть merge(), где задаются источник, условие сопоставления и действия. Такой отказ сохраняет границу API: библиотека не подменяет INSERT … ON CONFLICT произвольной последовательностью чтения и записи.

Различия конфликтов нужно учитывать и среди диалектов, где вызов поддерживается. MySQL реагирует на нарушение первичного или уникального ключа, а PostgreSQL требует цель конфликта для DO UPDATE. В текущем Query Builder она выводится из колонок вставки: id, если он присутствует, иначе первая колонка. При нескольких уникальных ограничениях нельзя предполагать, что базы выберут один и тот же конфликт.

Поэтому переносимый код опирается на определённую схему и контракт записи. Если приложению нужен один метод для всех шести СУБД, место выбора стратегии UPSERT/MERGE должно быть явным и проверяемым.

Эмуляция должна сохранять результат

Иногда возможность можно выразить другими средствами SQL. Условная сумма amount по оплаченным строкам — один вызов: прикладной код задаёт отбор, диалект выбирает форму.

PHP 8.4+ · условная сумма через Query Builder
use tommyknocker\pdodb\helpers\Db;

$row = $db->find()
    ->from('payments')
    ->select(['paid_sum' => Db::sum('amount')->filter('status', 'paid')])
    ->getOne();
SQL · native FILTER и emulate CASE
-- PostgreSQL / SQLite: native FILTER
SUM(amount) FILTER (WHERE status = :status)

-- MySQL / MariaDB / MSSQL / Oracle: CASE
SUM(CASE WHEN status = :status THEN amount END)

PostgreSQL и SQLite формируют FILTER; MySQL, MariaDB, SQL Server и Oracle переносят условие в CASE. RawValueResolver проверяет supportsFilterClause() и выбирает форму. Вызов один, SQL разный.

Граница корректности здесь проходит по результату: совпадает ли сумма, как обрабатываются NULL и отсутствие подходящих строк. Генерация похожего SQL недостаточна. Для каждой поддержанной формы агрегации нужны свои проверки; успешный пример SUM не доказывает эквивалентность всех агрегатов.

Отсюда три варианта поддержки: использовать нативную конструкцию; выразить операцию иначе с сохранением оговорённой семантики; отклонить её. Последний вариант нужен там, где автоматическая замена не обеспечивает ожидаемое поведение.

Почему Oracle и SQL Server требуют отдельной работы

Поддержка диалекта затрагивает больше, чем SELECT и кавычки. Способ получения идентификатора после вставки тоже зависит от СУБД.

Предположим, что в таблице настроена генерация числового первичного ключа. Для Oracle в этом примере используется последовательность events_seq, из которой при вставке берётся значение ключа — например, через триггер.

PHP 8.4+ · идентификатор после вставки
$id = $db->find()
    ->table('events')
    ->insert(['value' => 15]);

При настроенной генерации ключа MySQL, MariaDB, PostgreSQL, SQLite и SQL Server получают идентификатор через PDO lastInsertId. Для Oracle PDOdb использует отдельный путь: диалект читает CURRVAL последовательности table_seq, имя которой строится из таблицы по соглашению. Для events это events_seq. Прикладной вызов остаётся тем же; соглашение о последовательности нужно соблюдать в схеме БД.

У SQL Server отдельная работа уже была видна в пагинации OFFSET FETCH и в отказе onDuplicate в пользу merge(). В этом примере идентификатор столбца IDENTITY получается через lastInsertId.

PHP 8.4+ · текстовый скаляр из JSON
$row = $db->find()
    ->from('events')
    ->select(['status' => Db::jsonGet('payload', ['status'])])
    ->getOne();

JSON — вторая такая граница. Один вызов Db::jsonGet даёт текстовый скаляр: JSON_UNQUOTE/JSON_EXTRACT в MySQL, оператор #>> в PostgreSQL, json_extract в SQLite, JSON_VALUE в SQL Server и Oracle. Отсутствующее поле, JSON null, SQL NULL, число и строка требуют явных ожиданий. Скаляр не равен JSON-объекту: для объекта у SQL Server и Oracle другая функция, JSON_QUERY.

В CI PDOdb есть отдельные окружения Oracle XE 21 и SQL Server 2022. Это важно для проверки драйверов и выполнения запросов: успешная генерация SQL на машине без этих серверов их не заменяет. Название СУБД также не означает поддержку всех её версий; используемые конструкции должны соответствовать версии окружения.

Как проверяется общий контракт

В проекте проверяются и реализация библиотеки, и примеры. В CI выделены задания для MySQL, MariaDB, PostgreSQL, SQLite, MSSQL и Oracle; отдельно запускается PHPStan уровня 8. Вместе с документацией поддерживается каталог исполняемых примеров examples/. Скрипт test-examples.sh запускает их в доступных для соответствующего задания окружениях.

У каждого вида проверки своя роль. PHPStan проверяет типовые контракты, но не семантику SQL. Проверка строки запроса помогает обнаружить изменение генератора. Интеграционный тест выполняет запрос и проверяет данные: например, тесты UPSERT проверяют и сформированную конструкцию, и новое значение строки. Для неподдерживаемого пути MSSQL/Oracle проверяется ожидаемое исключение.

На момент подготовки статьи бейдж Codecov для master показывает 65%. Это показатель выполнения исходников тестами, не доля доказанной корректности. Исполняемые примеры помогают обнаруживать рассогласование документации и API; проверки результата остаются задачей тестов.

  • Для чтения: состав строк, порядок, границы страницы и типы значений.
  • Для записи: конфликт по нужному ограничению, число и содержимое изменённых строк, откат при ошибке.
  • Для JSON и выражений: отсутствующие значения, NULL, типы и граничные случаи.
  • Для refuse: понятная ошибка именно неподдерживаемой операции до выполнения её SQL.

Эти проверки формируют матрицу совместимости. Найденные расхождения нужно закреплять регрессионными тестами.

Что получает команда и сколько это стоит

Сильная сторона общего Query Builder — одно место для правил построения SQL и возможность использовать прикладной код в разных поддерживаемых окружениях. Переносимость достигается выбранным набором операций, совместимой схемой, определёнными ожиданиями от результата и тестированием целевых баз.

Из этого не следует одинаковая производительность или автоматическая переносимость всей системы. Raw SQL, миграции, collation, особенности типов, индексы, блокировки и уровни изоляции остаются частью решения. Эти условия стоит определить до миграции, а не выяснять после замены драйвера.

PDOdb пока имеет небольшое распространение; команде нужно освоить её API и оценить зависимость от сопровождения проекта. Документация с базовыми и продвинутыми примерами уменьшает стоимость знакомства, но не отменяет её. Независимость от фреймворка также не означает отсутствия Composer-зависимостей: библиотека использует PSR-контракты.

Я бы оценивал внедрение на нескольких операциях своего приложения: выполняется ли один код на нужных базах, совпадает ли результат, где понадобилась отдельная стратегия и понятна ли эта граница следующему разработчику. Такой пробный перенос даёт команде больше оснований для выбора, чем длина списка поддерживаемых функций.

← На главную