Skip to content

Navigation Menu

Sign in
Sign up

Latest commit

History

93 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

SQL template engine (SQL шаблонизатор)

SQL шаблонизатор — это инструмент, облегчающий конструирование SQL запросов. В SQL шаблон вместо меток-заменителей (placeholders) подставляются настоящие данные (строки, числа, булевы, null, массивы, объекты). Важно, что при подстановке данных в шаблон они квотируются, чтобы недопустить SQL инъекций. Далее готовый SQL запрос уже можно выполнить в СУБД (PostgreSQL, MySQL, ClickHouse).

Основное преимущество этого шаблонизатора — это формирование сложных SQL запросов в читабельном виде.

Этот SQL шаблонизатор может применяться вместо ORM и Query Builder. См. таблицу сравнения.

Он работает очень быстро, т.к. регулярные выражения для синтаксического анализа (парсинга) SQL почти не используются.

Реализация

Шаблонизатор представляет собой один PHP файл SqlTemplate.php, в котором всего 2 публичных метода.

/**
 * Шаблонизатор SQL
 * Документация: https://github.com/rin-nas/sql-template-engine/edit/master/README.md
 *
 * @param string $sql Шаблон SQL запроса с необязательными метками-заменителями и условными блоками
 * @param array $placeholders Ассоциативный массив, где ключи — это метки-заменители,
 * а значения, это данные (любые типы), которые нужно заквотировать
 * @param object $quotation Объект, отвечающий за квотирование. Должен иметь методы:
 * * quote() -- для квотирования данных
 * * quoteField() -- для квотирования идентификатора объекта БД (название таблицы, колонки, процедуры и т.п.)
 *
 * @return SqlExpression Объект, в котором хранится готовое sql выражение
 * Объект имеет метод __toString(), возвращающий готовое sql выражение
 * Объект позволяет вставлять готовые куски SQL друг в друга без повторного квотирования
 * @throws \Exception
 * @link http://php.net/manual/en/pdo.prepare.php
 */
public static function bind(string $sql, array $placeholders, $quotation) : SqlExpression
/**
 * Делает bind() для каждого элемента переданного массива
 *
 * @param string $sql Шаблон SQL запроса с метками-заменителями
 * @param array $values Ассоциативный массив
 * Для строковых ключей массива в SQL шаблоне зарезервирована метка-заменитель @key
 * Для значений массива в SQL шаблоне зарезервированы метки-заменители:
 * :key, :row, :row[], :value, :value[]
 * @param object $quotation Объект, отвечающий за квотирование. Должен иметь методы quote() и quoteField()
 *
 * @return SqlExpression[]
 * @throws \Exception
 */
public static function bindEach(string $sql, array $values, $quotation) : array

Замена именованных меток-заменителей

Синтаксис меток-заменителей в терминах регулярных выражений PCRE: ~ [:@?] [a-zA-Z_]+ [a-zA-Z_\d]* (?:\[\])? ~sxSX

  1. :value, где value — это произвольное название. Квотирует значение, массив возвращается в синтаксисе {...}. Возможные типы значений: string, integer, float, boolean, null, array, SqlExpression, \DateTime.

  2. @field, где field — это произвольное название. Квотирует идентификатор объекта БД (название таблицы, колонки, процедуры и т.п.). Возможные типы значений: string, array, SqlExpression.

  3. :values[] На входе ожидает массив и квотирует каждый его элемент как значение и склеивает их через запятую. Если передан НЕ массив, то работает аналогично метке-заменителю :value. Удобно использовать следующих конструкциях, примеры: IN(:values[]), VALUES(:values[]), COALESCE(:values[]), ROW(:values[]), ARRAY[:values[]]

  4. @fields[] На входе ожидает массив и квотирует каждый его элемент как идентификатор объекта БД (название таблицы, колонки, процедуры и т.п.) и склеивает их через запятую. Если передан НЕ массив, то работает аналогично метке-заменителю @field. Удобно использовать следующих конструкциях, примеры: INSERT INTO t (@fields[]) ..., COALESCE(@fields[]), ROW(@fields[]), ARRAY[@fields[]]

  5. ?field, где field — это произвольное название. Эта метка-заменитель называется условной, т.к. используется только внури условных блоков (может стоять там в любой позиции). На входе ожидает логическое значение. Нелогическое значение приводит к логическому по правилам PHP (if ($value) ...). Если true, то условный блок, в котором находится эта метка-заменитель, и вложенные блоки будут сохранены, а сама метка-заменитель будет удалена. Если false, то условный блок, в котором находится эта метка-заменитель, и вложенные блоки будут удалены.

Обработка условных блоков

Условный блок — это часть SQL запроса между фигурными скобками { и }.

  1. Блок будет удалён (со всеми вложенными блоками), если в нём есть хотябы одна метка-заменитель, название которой нет в ключах переданного массива меток-заменителей или её значение равно BIND_SKIP.
  2. Иначе блок будет сохранён (вложенные блоки независимы). При этом:
    • Если значение метки-заменителя НЕ равно BIND_KEEP, то метка будет заменена на квотированное значение.
    • Если значение метки-заменителя равно BIND_KEEP, то метка будет удалена. Фактически BIND_KEEP удобно использовать только для условных меток-заменителей.

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

SELECT {?total COUNT(*)}
 {?fields 
 t.*
 {, EXISTS(SELECT r.col FROM r WHERE r.id = t.id AND r.exists_id = :exists_id) AS exists}
 }
 FROM t
 WHERE TRUE
 {AND @col = :val}
 {AND t.id IN (:ids[])}
{?fields
 ORDER BY t.name
 {LIMIT :limit}
 {OFFSET :offset}
}

Пример использования 1

$data = [
 [
 'person_id' => null,
 'created_at' => (new \DateTime()),
 'recipient' => 'test1@mail.ru',
 'scores' => 10,
 'reason' => 'Невозможно доставить сообщение',
 ],
 [
 'person_id' => 555,
 'created_at' => '2020年09月10日 10:30:00',
 'recipient' => 'test2@mail.ru',
 'scores' => 90,
 'reason' => 'Email не существует',
 ],
 //...
];
$sql = $this->db->bind(
 'INSERT INTO @table (@fields[])
 VALUES :rows[]
 ON CONFLICT (lower(@recipient)) 
 DO UPDATE SET 
 @scores = LEAST(@table.@scores + EXCLUDED.@scores, :maxScores), 
 @reason = EXCLUDED.@reason',
 [
 '@table' => self::_TABLE,
 '@fields[]' => array_keys(reset($data)),
 '@recipient' => self::RECIPIENT,
 '@scores' => self::SCORES,
 '@reason' => self::REASON,
 ':rows[]' => $this->db->bindEach('(:row[])', $data),
 ':maxScores' => 100,
 ]
);
$this->db->query($sql);

Результат в $sql (код отформатирован для удобства чтения):

INSERT INTO "user_email_blacklist" ("person_id", "created_at", "recipient", "scores", "reason")
VALUES 
 (NULL, '2020年09月13日T11:08:40.597+03:00'::timestamptz, 'test1@mail.ru', 10, 'Невозможно доставить сообщение'), 
 (555, '2020年09月10日 10:30:00', 'test2@mail.ru', 90, 'Email не существует')
ON CONFLICT (lower("recipient")) 
DO UPDATE SET 
 "scores" = LEAST("user_email_blacklist"."scores" + EXCLUDED."scores", 100), 
 "reason" = EXCLUDED."reason"

Пример использования 2

/**
 *
 * @param mixed[] $filter
 * @param array $fields
 * @param string $sort
 * @param int $limit
 * @param int $offset
 * @param bool $isCountTotal
 *
 * @return array[]|int
 * @throws \Rdw\X\Db\Exceptions\DbException
 * @throws \Exception
 */
public function getByFilter(
 array $filter = [],
 array $fields = [],
 string $sort = '',
 int $limit = 20,
 int $offset = 0,
 bool $isCountTotal = false
) {
 if (count($fields) > 0) {
 $this->selectAll = false;
 }
 $this->select = $fields;
 $this->return = [];
 $placeholders = [
 '?total' => $isCountTotal,
 '?fields' => ! $isCountTotal,
 '?has_subway' => $this->fieldSelected(static::FIELD_HAS_SUBWAY),
 '?ids' => count($filter['ids'] ?? []) > 0,
 '?not_ids' => count($filter['not_ids'] ?? []) > 0,
 '?type_ids' => count($filter['type_ids'] ?? []) > 0,
 '?query' => strlen($filter['query'] ?? '') > 0,
 '?big_cities_only' => ($filter['big_cities_only'] ?? false),
 '?sort_name' => $sort === self::SORT_NAME,
 '?sort_order_num' => $sort === self::SORT_ORDER_NUMBER,
 '?sort_query_rank' => $sort === self::SORT_QUERY_RANK,
 '?country_ids' => !empty($filter['country_ids'] ?? []),
 ':specify' => in_array(static::FIELD_SPECIFY, $fields)
 ? $this->db->bind(self::getFieldSpecifySQL(), [])
 : Database::BIND_SKIP,
 ':ids[]' => ($filter['ids'] ?? Database::BIND_SKIP),
 ':not_ids[]' => ($filter['not_ids'] ?? Database::BIND_SKIP),
 ':type_ids[]' => ($filter['type_ids'] ?? Database::BIND_SKIP),
 ':query' => ($filter['query'] ?? Database::BIND_SKIP),
 ':country_ids[]' => ($filter['country_ids'] ?? Database::BIND_SKIP),
 ':limit' => $limit,
 ':offset' => $offset,
 ];
 $field = static::FIELD_PAID_MODEL_AVAILABLE;
 if (($filter[$field] ?? null) !== null) {
 $placeholders['?filter_paid_model'] = true;
 $placeholders['?filter_pm_condition'] = !$filter[$field];
 $placeholders[':link_type_auto_bind'] = V3CityPublishers::LINK_TYPE_AUTO_BIND;
 }
 $sql = <<<'SQL'
{?query 
WITH
 normalize AS (
 SELECT ltrim(REGEXP_REPLACE(LOWER(:query::text), '[^а-яёa-z0-9]+', ' ', 'gi')) AS query
 ),
 vars AS (
 SELECT CONCAT('%', REPLACE(quote_like(trim(normalize.query)), ' ', '_%'), '%') AS query_like,
 CONCAT(
 '(?<![а-яёa-z0-9])', 
 REPLACE(quote_regexp(normalize.query), ' ', '(?:[^а-яёa-z0-9]+|$)')
 ) AS query_regexp
 FROM normalize
 )
}
SELECT
 {?total COUNT(*)}
 {?fields
 r.*,
 COALESCE(r.is_big_city, false) as is_big_city,
 {?has_subway EXISTS(SELECT 1 
 FROM v3_metro AS ms
 INNER JOIN v3_metro_branch AS mb ON mb.id = ms.metro_branch_id 
 WHERE mb.region_id = r.id 
 LIMIT 1) AS has_subway,}
 {:specify AS specify,}
 r.id
 }
FROM v3_region AS r
INNER JOIN v3_region_type AS rt ON r.region_type_id = rt.id
INNER JOIN v3_country AS c ON c.id = r.country_id
{?query , vars, normalize}
WHERE true
 AND r.city_id IS NOT NULL
 AND r.id != 1954 -- "Дальний Восток" исключается, т.к. для РФ это территория из нескольких субъектов федерации!
 AND r.region_type_id != 12
 {?type_ids AND r.region_type_id IN (:type_ids[])}
 {?ids AND r.id IN (:ids[])}
 {?not_ids AND r.id NOT IN (:not_ids[])}
 {?big_cities_only AND r.is_big_city = true}
 {?filter_paid_model AND {?filter_pm_condition NOT }
 EXISTS(SELECT 1 FROM v3_city INNER JOIN v3_city_publisher cp ON v3_city.id = cp.city_id 
 WHERE r.city_id=v3_city.id AND cp.link_type=:link_type_auto_bind)
 }
 {?query
 AND length(rtrim(normalize.query)) > 0 -- для скорости
 AND lower(r.name) LIKE vars.query_like -- для скорости
 AND lower(r.name) ~* vars.query_regexp -- для точности
 }
 {?country_ids AND r.country_id IN (:country_ids[])}
{?fields
 {?sort_name ORDER BY r.name, r.order_num}
 {?sort_order_num ORDER BY r.order_num, NLEVEL(r.ltree_path), name}
 {?sort_query_rank ?query ORDER BY lower(r.name) LIKE RTRIM(normalize.query), LENGTH(r.name), r.name}
 LIMIT :limit
 OFFSET :offset
}
SQL;
 $sql = $this->db->bind($sql, $placeholders);
 if ($isCountTotal) {
 return $this->db->fetchOne($sql);
 }
 return $this->db->fetchAll($sql);
}

Замечания по безопасности

Не используйте метки-заменители и фигурные скобки { и } внутри комментариев /*...*/ и --...! Шаблонизатор про комментарии ничего не знает.

Исходный шаблон запроса:

SELECT /* @col1, */ col2 FROM t

Если значение метки @col1 будет */, то SQL запрос сломается. Более того, код становится небезопасным и открываются возможности для инъекции зловредного кода (XSS).

Сгенерированный SQL:

SELECT /* " */ ", */ col2 FROM t

История возникновения

Некоторые идеи были взяты из библиотеки DbSimple. Описание идей на русском языке.

About

SQL template engine (шаблонизатор). Инструмент, облегчающий конструирование SQL запросов

Resources

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /