Тарасов С. В. СУБД для программиста. Базы данных изнутри
.pdfТакой подход к программированию запросов с параметрами хоть и может показаться простым и удобным, имеет ряд серьёзных недостатков и проблем.
1.Необходимо соблюдать формат данных для преобразования значений в строку. Например, использование конвертации
Date1.ToString или FloatToStr(Value2) не является безопасным и зависит от региональных установок пользовательского компьютера и сервера.
2.Тело запроса постоянно меняется, что может оказать негативное влияние на производительность за счёт перекомпиляции и вероятного пропуска процедурного кэша на уровне СУБД.
3.Для строк необходимо учитывать внутренние кавычки и апострофы. Хотя стандарт SQL использует апострофы (одиночные кавычки) для строковых констант, некоторые СУБД для обеспечения совместимости принимают и двойные, что увеличивает число проверок. Внутренняя кавычка или апостроф могут по разному обозначаться в теле константы, например, двойным повторением или специальным предшествующим символом. Так, константа 'Жанна д'Арк' должна быть в большинстве случаев преобразована в 'Жанна д''Арк', иначе СУБД вернёт синтаксическую ошибку.
Последний из приведённых пунктов является основой для хакерской атаки типа SQL-инъекция (SQL injection).
Предположим, что образец текста, напрямую вводимый пользователем, используется для поиска в запросе согласно приведённому выше примеру.
SQL.Text := 'SELECT * FROM clients WHERE name LIKE ''' + EditBox.Text + '''';
Злоумышленник может набрать в поле ввода например, такой текст.
Hello';UPDATE users SET password_hash = 0x83218ac34c1834c26781fe4bde918ee4 WHERE name='admin
Программа преобразует запрос в следующий вид.
201
SELECT * FROM clients WHERE name LIKE 'Hello';UPDATE users SET password_hash = 0x83218ac34c1834c26781fe4bde918ee4 WHERE name='admin'
Вместо одного запроса стало два. Первый, как и прежде, выводит список клиентов по фильтру названия. Но к нему «паровозиком» прицеплен второй, обновляющий значение хеша пароля администратора. Теперь хакер беспрепятственно может войти в систему с правами администратора, потому что кто-то в спешке или по незнанию написал небезопасный код. Аналогичным образом, даже проще, внедрить запрос можно в вебприложении, через параметры URL или симуляцией отправки формы.
Чтобы избежать перечисленных недостатков и проблем безопасности возьмите за правило.
При динамическом формировании SQL в программе всегда пользуйтесь функциями или объектами, позволяющими управлять параметрами.
Возвращаясь к самому первому запросу, безопасный и не ухудшающий производительность код на Delphi/FreePascal выглядит так.
with TUniQuery.Create(nil); try
Connection := UniConnection1;
SQL.Text := 'SELECT * FROM clients WHERE name LIKE :ClientName';
ParamByName('ClientName').AsString := EditBox1.Text; Open;
while not EOF do begin
// обработка текущей записи
Next;
end; finally Free;
end;
Аналогично в C#.NET.
using (var conn = new SqlConnection(MyConnectionString))
{
conn.Open();
SqlCommand cmd = new SqlCommand(
202
"SELECT * FROM clients WHERE name LIKE (@ClientName)", conn);
cmd.Parameters.AddWithValue("@ClientName", editBox1.Text); SqlDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
// обработка текущей записи
}
}
Практически все фреймворки и библиотеки компонентов доступа к данным поддерживают возможности по управлению параметрами запросов, обратитесь к соответствующему разделу документации для справки.
Сравнение с неопределёнными (пустыми) значениями
Неопределённые (пустые) значения в SQL обозначаются, как NULL. Некоторые СУБД для совместимости поддерживают весьма странные операции сравнения, вроде column1 = NULL. Однако, вас не должно это подвигать к написанию подобного кода. Согласно стандарту, да и самой логике неопределённых значений, любая операция сравнения с таким значением выдаёт «ложь». Поэтому следуйте простому правилу.
Для нахождения неопределённых значений всегда используйте только две операции сравнения IS NULL и IS NOT NULL.
Например:
/* Всегда возвращает пустой результат */ SELECT *
FROM persons
WHERE middle_name <> NULL
/* Возвращает контакты с отчеством */ SELECT *
FROM persons
WHERE middle_name IS NOT NULL
В некоторых СУБД поведение операции сравнения с неопределённым значением можно регулировать установкой опции SET ANSI_NULL (ON|OFF).
203
Неопределённым становится и значение в других операциях, например при слиянии строк или в математических вычислениях. Например, следующийзапросвернётпустыестрокидля всехконтактов, у которыхимя не определено (IS NULL).
SELECT 'Здравствуйте, ' || first_name FROM persons
Чтобы избежать появления в результате пустых строк, добавляйте соответствующее условие фильтрации.
SELECT 'Здравствуйте, ' || first_name FROM persons
WHERE first_name IS NOT NULL
Аналогично обстоит дело с арифметикой и другими расчётами. Запрос ниже вернёт неопределённые значения возраста для всех контактов, у которых не заполнено поле «Дата рождения».
/* SQL Server */
SELECT datediff(year, birth_date, getdate()) AS age FROM persons
Возможно, это то, что вы и ожидали получить, но подобное ожидание всегда должно основываться на знании механизмов работы с неопределёнными значениями.
Работа со строками
ОбработкастрокнаSQL имеетнекоторыеособенности, специфичныедля разных СУБД.
Начнём с конкатенации. Стандарт предписывает использование двойной вертикальной черты || для операции соединения двух строк. Однако, например, Microsoft SQL Server не поддерживает этот способ, обязуя применять знак сложения +.
/* PostgreSQL */
SELECT 'Hello,' || 'world!' /* SQL Server*/
SELECT 'Hello,' + 'world!'
204
Данная проблема относится к обеспечению переносимости, поэтому для её решения потребуются уже рассмотренные приёмы, предусмотренные ещё на уровне проектирования.
Следующий пункт — понятие «пустая строка». Подчеркну, что это именно пустая строка, а не пустое значение NULL, имеющее семантику неопределённости. Между «значение неизвестно» и «значение отсутствует» есть большая смысловая разница.
Константа, обозначающая пустую строку, как правило, задаётся в виде двух подряд идущих апострофов. Ожидаемая длина пустой строки — ноль. Однако, так реализовано не везде.
length('') /* Возвращает 0 на PostgreSQL */ length('') /* Возвращает NULL на Oracle */
За тонкостями следует обращаться к документации, но если вы хотите сразу написать переносимый вариант SQL-запроса, то пригодится функция COALESCE или её более громоздкий аналог через CASE.
SELECT * FROM persons
WHERE coalesce(lenght(middle_name), 0) = 0
Через CASE:
SELECT * FROM persons WHERE
CASE
WHEN lenght(middle_name) IS NULL THEN 0 ELSE lenght(middle_name)
END = 0
Конкатенация с пустой строкой также может иметь разное поведение. В Oracle фактически не различаются пустые строки и неопределённые значения. Несмотря на то, что длина пустой строки для Oracle это NULL, любое неопределённое значение трактуется как пустая строка.
/* Возвращает NULL на PostgreSQL*/ SELECT 'Тест' || NULL
/* Возвращает 'Тест' на Oracle*/ SELECT 'Тест' || NULL FROM dual
205
Весьма интересной особенностью является сравнение строк при наличии завершающих пробелов. Исторически, строки постоянной и переменной длины хранились в БД согласно заданному размеру, недостающие символы заполнялись пробелами. Для управления поведением хранения данных, соответствующая опция определена в некоторых СУБД, она управляется через SET ANSI_PADDING со значениямON (поумолчанию) илиOFF. При значении ON завершающие пробелы не обрезаются и наоборот.
В связи с особенностями хранения, сравнение строк проводилось без учёта завершающих пробелов, перекочевав на уровень стандарта SQL-92.
/* Оба запроса вернут одинаковые результаты */ |
|
SELECT * FROM persons WHERE first_name = 'Иван'; |
'; |
SELECT * FROM persons WHERE first_name = 'Иван |
ВSQL-99 былосделаноуточнение, что сравнение строксзавершающими пробелами должно зависеть от текущего порядка сопоставления символов (collation) соответствующего строкового типа. Однако, эта возможность реализована не везде, а если и реализована, то не всегда полно.
Например, в PostgreSQL в текущей на данный момент версии 9 все порядки сопоставления имеют по умолчанию опцию NO PAD, альтернативная опция PAD SPACE не реализована (см. раздел документации 34.10. collations). Это означает, что запрос ниже не вернёт результата.
|
|
SELECT * FROM persons WHERE first_name = 'Иван |
'; |
В Firebird 2.5 для обеспечения поведения NO PAD необходима создание пользовательских типов.
CREATE COLLATION collation1 FOR iso8859_1 FROM en_US NO PAD; CREATE COLLATION collation2 FOR iso8859_1 FROM en_US PAD SPACE;
CREATE DOMAIN domain1 varchar(20) character set iso8859_1 collate collation1;
CREATE DOMAIN domain2 varchar(20) character set iso8859_1 collate collation2;
/* Запрос не находит совпадений */ SELECT *
FROM persons
206
|
|
WHERE CAST(first_name AS TYPE OF domain1) = 'Иван |
'; |
/* Запрос возвращает результат, игнорируя пробелы */ |
|
SELECT * |
|
FROM persons |
'; |
WHERE CAST(first_name AS TYPE OF domain2) = 'Иван |
Также следует помнить, что длина строк в символах и байтах может быть разной, если используются типы постоянной длины или мультибайтные Unicode-кодировки. Ниже пример для PostgreSQL при используемой по умолчанию кодировке UTF-8.
/* Возвращает длины 4 и 10 */
SELECT length(CAST('Test' AS char(10))), octet_length(CAST('Test' AS char(10)));
/* Возвращает длины 4 и 8 */ SELECT length('Тест'),
octet_length('Тест');
Работа с датами
Часто возникает вопрос о том, как задать константу типа даты или пары даты-времени. Программист пробует писать в запросах нечто похожее на следующий код.
SELECT * FROM orders WHERE created > '15/05/2014'
С большой вероятностью запрос проходит на локальном сервере без ошибок, но у клиента из другой страны вдруг возникает ошибка.
Проблема кроется в региональных форматах даты и времени, используемых по умолчанию. В примере использовался привычный для европейскихстранформат«день/месяц/год», тогдакаквСевернойАмерике используется «месяц/день/год». Также может иметь значение символ разделителя, и вместо косой черты использоваться точка или тире.
Чтобы застраховаться от ошибок подобного рода необходимо использовать международные форматы даты и времени, регламентируемые стандартом ISO 8601. В этом случае для даты можно выбрать фиксированный формат «ггггммдд», а для времени «ггггммддTчч:мм:сс».
Переписанный надлежащим образом запрос будет выглядеть так.
SELECT * FROM orders WHERE created > '2014-05-15'
207
Некоторую сложность представляют собой проверки попадания значения в заданный интервал дат. Оператор BETWEEN определяет закрытый интервал, то есть граничные значения включаются в результат. Например, если вы хотите выбрать заказы за неделю, начиная с заданной даты, то нельзя просто добавить 7 дней и написать запрос.
SELECT * FROM orders
WHERE created BETWEEN '2014-05-15' AND
TIMESTAMP '2014-05-15' + INTERVAL '7 days'
Если тип колонки created включает только дату, то прибавлять нужно не 7, а 6 дней. Но если тип содержит ещё и время с точностью до секунд, то такой приём не сработает, потому что все заказы седьмого дня не попадут в выборку. Выходом в такой ситуации является прибавление 7 дней, с последующим вычитанием одной секунды.
SELECT * FROM orders
WHERE created BETWEEN '2014-05-15' AND
TIMESTAMP '2014-05-15' + INTERVAL '7 days' - INTERVAL '1 seconds'
Заметим, что данный код является непереносимым, так как использует специфичные для PostgreSQL функции работы с датами и временим. Например, в SQL Server аналогичная функция называется dateadd. Ну вот, ещё одна проблема! Но решить её можно достаточно просто: не кодируйте константы в запросе, используйте параметрические запросы, упомянутые выше, азначениядатвычисляйтенепосредственновпрограмме— принцип сравнения останется прежним.
Облегчить понимание логики сравнения периодов поможет следующий пример.
SELECT *
FROM (
SELECT '2014-02-03' AS d1 UNION
SELECT '2014-02-03T23:59:59' AS d1 UNION
208
SELECT '2014-02-04' AS d1 UNION
SELECT '2014-02-04T00:00:01' AS d1 UNION
SELECT '2014-02-05' AS d1 UNION
SELECT '2014-02-06' AS d1 UNION
SELECT '2014-02-06T00:00:01' AS d1 UNION
SELECT '2014-02-07' AS d1 ) AS t
WHERE t.d1 BETWEEN '2014-02-04' AND '2014-02-06' ORDER BY 1
Результат
d1
-------------------
2014-02-04
2014-02-04T00:00:01
2014-02-05
2014-02-06
Генерация идентификаторов записей
Использование суррогатных идентификаторов в качестве ключей приводит к необходимости генерировать неповторяющиеся значения. Физическое упорядочивание таблицы в кластер по первичному ключу добавляет рекомендацию к использованию возрастающих последовательностей таких значений. Наилучшим вариантом с точки зрения физической организации будет упорядоченное множество натуральных чисел.
Последовательности и генераторы
Большинство СУБД предоставляют программисту возможности генерацииидентификаторовпосредствомпоследовательностей(sequences). Стандарт SQL-2003 явным образом вводит это понятие. Однако, например, разработчики SQL Server реализовали соответствующий функционал только в версии 2012. Ещё одна иллюстрация разницы между двумя мирами.
209
Пример для PostgreSQL.
/* Создание последовательности */ CREATE SEQUENCE seq_orders START WITH 1; /* Получение следующего значения */ SELECT nextval('seq_orders');
/* Получение текущего значения (без приращения) */ SELECT currval('seq_orders');
Как лучше организовать работу с последовательностями? Существует несколько основных вариантов:
установка значения по умолчанию;
реализация триггера вставки;
получение и установка значений в приложении.
Установка значения по умолчанию состоит в создании последовательности и назначении соответствующей колонке функции, возвращающей следующее значение. Колонка таким образом превращается в автоинкрементную.
CREATE TABLE products (
id_product integer DEFAULT nextval('seq_products'),
...
);
Определив выражение для значения по умолчанию, можно осуществлять вставку в таблицу, не указывая значения для колонки идентификатора.
Если СУБД не поддерживает возможность указания функций типа nextval в качестве значению по умолчанию или же одновременно требуется иметь возможность вставки записей с явно указанными значениями, то необходимо использовать триггер события before insert.
/* Пример для Firebird */
CREATE TRIGGER orders_bi_trigger FOR orders BEFORE INSERT AS
BEGIN
IF (NEW.id_order IS NULL) THEN NEW.id_order = GEN_ID(seq_orders, 1);
END;
/* Пример для Oracle */
210