Добавил:
СПбГУТ * ИКСС * Программная инженерия Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Тарасов С. В. СУБД для программиста. Базы данных изнутри

.pdf
Скачиваний:
80
Добавлен:
29.11.2021
Размер:
4.08 Mб
Скачать

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

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