���������� ������-������ 2009'3 |
|||||||
|
SQL-программирование может быть и увлекательным, и многообещающим занятием. Люди, имеющие опыт программирования на таких традиционных языках программирования, как Java, C, C++ или VB, часто испытывают затруднения с адаптацией к "set-based"-образу мыслей. Даже бывалые SQL-программисты и DBA могут угодить в одну из ловушек языка SQL. Я там уже бывал, и могу поспорить, что и вы тоже. Изучение основ занимает время, и даже при этом некоторые ошибки трудно отследить.
В этой статье я намереваюсь осветить некоторые из наиболее распространенных ошибок, совершаемых людьми, пишущими SQL-код. Их список отражает мой опыт работы с различными командами разработчиков и программистами, выполняющими ревизии кода, и т.д., так же как проблемы, обсуждения которых я ежедневно вижу в форумах. Это далеко не исчерпывающий список, и ошибки в нем не отсортированы. На самом деле, этот список можно рассматривать как результат следующего запроса:
SELECT TOP(10) mistake FROM CommonSQLProgrammingMistakes ORDER BY CHECKSUM(NEWID()); |
Вот этот список:
Представленные примеры используют диалект Transact-SQL, но большинство концепций пригодны для любой реализации SQL.
Один из наиболее распространенных запросов – выборка данных, основанная на неком значении колонки, не входящем в список значений. Следующие две таблицы иллюстрируют этот сценарий. У нас есть таблицы с цветами и с продуктами.
Таблица цветов:
color ---------- Black Blue Green Red |
sku product_description color ---- -------------------- ------ 1 Ball Red 2 Bike Blue 3 Tent NULL |
Заметьте, что эти таблицы не блещут дизайном и не следуют правилам нормализации и передовому опыту. Однако это упрощенный сценарий, который должен помочь проиллюстрировать этот пример. На самом деле таблица colors должна бы содержать ключевую колонку с кодами цветов, на которую ссылалась бы таблица products.
Запрос должен выбрать список цветов, еще не использованных в продуктах. Другими словами, нам нужно сконструировать запрос, возвращающий только те цвета, для которых нет продуктов такого цвета. На первый взгляд может показаться, что предикат NOT IN предоставляет очень интуитивный способ создания такого запроса, очень близкий к тому, как проблема выражается на простом английском языке:
SELECT C.color FROM Colors AS C WHERE C.color NOT IN (SELECT P.color FROM Products AS P); |
Вы можете ожидать, что запрос вернет две строки ('black' и 'green'), но на самом деле он возвратит пустой результат:
color ---------- (0 row(s) affected) |
Это, очевидно, «неверно». В чем проблема? Просто SQL использует троичную логику, управляемую наличием NULL, являющимся не значением, а маркером, указывающим на отсутствующую (или неизвестную) информацию. Когда оператор NOT применяется к списку значений из подзапроса в предикате in, это переводится как:
"color NOT IN (Red, Blue, NULL)" |
Что эквивалентно:
"NOT(color=Red OR color=Blue OR color=NULL)" |
Выражение "color=NULL" вычисляется как UNKNOWN и, по правилам троичной логики, NOT UNKNOWN также сводится к UNKNOWN. В результате все строки отфильтровываются, и запрос выдает пустой результат.
Эта ошибка часто всплывает при изменении требований, когда ранее не содержавшая NULL колонка изменяется и начинает содержать NULL. Это также подчеркивает потребность в тщательном тестировании. Даже если в исходном дизайне колонка не допускала наличия NULL, нужно убедиться, что запросы будут нормально работать с NULL.
Одно из решений – использовать предикат EXISTS вместо IN, так как EXISTS использует двоичную логику предиката, то есть TRUE/FALSE:
SELECT C.color FROM Colors AS C WHERE NOT EXISTS(SELECT * FROM Products AS P WHERE C.color = P.color); |
Этот запрос корректно вернет ожидаемый набор результатов:
color |
Еще одно решение выглядит так:
/* IS NOT NULL in the subquery */ SELECT C.color FROM Colors AS C WHERE C.color NOT IN (SELECT P.color FROM Products AS P WHERE P.color IS NOT NULL); /* EXCEPT */ SELECT color FROM Colors EXCEPT SELECT color FROM Products; /* LEFT OUTER JOIN */ SELECT C.color FROM Colors AS C LEFT OUTER JOIN Products AS P ON C.color = P.color WHERE P.color IS NULL; |
Хотя все решения дают желаемый результат, использование EXCEPT может быть самым простым в понимании и использовании. Заметьте, что оператор EXCEPT выдает конкретные значения, что прекрасно работает в нашем сценарии, но может быть неверно в других случаях.
Мы часто пробуем писать код путем прямого перевода исходного требования. Например, если у нас просят выбрать всех покупателей, чьи имена начинаются с буквы L, естественно написать примерно вот такой запрос, используя функцию LEFT, чтобы получить первые буквы имен:
SELECT customer_name FROM Customers WHERE LEFT(customer_name, 1) = 'L'; |
Вместо этого, если бы нас попросили подсчитать продажи за январь 2009, мы можем написать запрос типа следующего, использующий функцию DATEPART для извлечения нужных месяца и года из колонки sale_date:
SELECT SUM(sale_amount) AS total_sales FROM Sales WHERE DATEPART(YEAR, sale_date) = 2009 AND DATEPART(MONTH, sale_date) = 1; |
Эти запросы выглядят очень интуитивно, но вы обнаружите, что индексы, которые у вас (разумеется!) есть для колонок customer_name и sale_date, не используются, и что планы исполнения этих запросов показывают сканирование.
Проблема возникает из-за того, что функции передаются колонки индекса, которые затем движок запросов должен перебирать по одной строке. В таких случаях предикат выражения WHERE оказывается "non-SARGable" (SARG происходит от Search ARGument), и лучшее, что может сделать оптимизатор запросов – сканировать таблицу или индекс.
Чтобы убедиться, что индексы используются, нужно исключить использование функций для индексированных колонок. В наших двух примерах довольно просто переписать запросы для использования SARG-предикатов. Первый можно заменить таким логически эквивалентным запросом:
SELECT customer_name FROM Customers WHERE customer_name LIKE 'L%'; |
Эквивалентом второго запроса является следующее:
SELECT SUM(sale_amount) AS total_sales FROM Sales WHERE sale_date >= '20090101' AND sale_date < '20090201'; |
Эти два запроса, скорее всего, будут использовать поиск по индексу для быстрой и эффективной выборки данных.
Нужно заметить, что SQL Server развивается и становится «умнее». Например, рассмотрим следующий запрос, использующий функцию CAST для индексированной колонки sale_date:
SELECT SUM(sale_amount) AS total_sales FROM Sales WHERE CAST(sale_date AS DATE) = '20090101'; |
Если выполнить этот запрос на SQL Server 2005 или более раннем, вы увидите сканирование индекса. Но на SQL Server 2008 вы увидите поиск по индексу, несмотря на использование функции CAST. План исполнения показывает, что придикат трансформируется во что-то типа следующего:
SELECT SUM(sale_amount) AS total_sales FROM Sales WHERE sale_date >= '20090101' AND sale_date < '20090102'; |
Однако, в общем, там, где можно, нужно использовать SARG-предикаты, а не полагаться на рост интеллекта оптимизатора.
При написании подзапроса очень просто абстрагироваться от логики основного запроса и сконцентрироваться на самом подзапросе. Это может привести к случайным ошибках, заключающимся в подмене колонки таблицы, к которой обращается подзапрос, колонкой с похожим названием из основного запроса.
Давайте посмотрим на две очень простые таблицы. Одна – таблица Sales с данными о продажах, а другая – вспомогательная таблица Calendar, которая содержит календарные даты и праздники (здесь сокращенная).
Таблица Sales:
sale_date sale_amount ---------- ----------- 2009-01-01 120.50 2009-01-02 115.00 2009-01-03 140.80 2009-01-04 100.50 |
calendar_date holiday_name ------------- ---------------- 2009-01-01 New Year's Day 2009-01-02 NULL 2009-01-03 NULL 2009-01-04 NULL 2009-01-05 NULL |
Наша задача – получить данные о продажах только для праздников. Кажется, что написать такой запрос несложно:
SELECT sale_date, sale_amount FROM Sales AS S WHERE sale_date IN (SELECT sale_date FROM Calendar AS C WHERE holiday_name IS NOT NULL); |
Однако этот запрос просто возвращает все строки из таблицы Sales! Более пристальное рассмотрение запроса показывает, что виноват список SELECT в подзапросе. Он по ошибке ссылается на колонку sales_date из таблицы Sales вместо колонки calendar_date из таблицы Calendar.
Если проблема в этом, почему мы не получаем сообщение об ошибке? Несмотря на то, что результат не таков, как ожидалось, это по-прежнему корректное SQL-выражение. При использовании подзапросов колонки внешнего запроса видны внутреннему. Здесь мы неумышленно превратили самостоятельный подзапрос, который должен был сработать один раз и передать результат внешнему запросу, в коррелированный подзапрос, логически исполняемый по одному разу для каждой строки, возвращаемой внешним запросом.
В результате подзапрос сводится к sale_date IN (sale_date), что всегда верно, пока есть хоть один праздничный день в таблице Calendar, и наш набор результатов возвращает все строки из таблицы Sales. Конечно, это нетрудно исправить. Нужно просто использовать верную колонку из таблицы Calendar:
SELECT sale_date, sale_amount FROM Sales AS S WHERE sale_date IN (SELECT C.calendar_date FROM Calendar AS C WHERE C.holiday_name IS NOT NULL); |
Это показывает еще один важный момент: хорошо предварять имена колонок в подзапросах псевдонимами таблиц. Например, если бы мы использовали следующий псевдоним:
SELECT sale_date, sale_amount FROM Sales AS S WHERE sale_date IN (SELECT C.sale_date FROM Calendar AS C WHERE holiday_name IS NOT NULL); |
Этот запрос вернул бы ошибку "Error: Invalid column name 'sale_date'".
Это еще одна типичная ошибка, которую иногда трудно отловить. Очень просто ошибиться с типами данных в предикатах. Это может быть в хранимой процедуре, куда передаются параметры одного типа, а затем используются в запросе для фильтрации данных колонки другого типа. Другой пример – соединение таблиц по колонкам с различными типами, или просто использование предикатов с перепутанными типами данных.
Например, у нас может быть таблица Customers, в которой есть колонка last_name типа VARCHAR:
CREATE TABLE Customers ( customer_nbr INT NOT NULL PRIMARY KEY, first_name VARCHAR(35) NOT NULL, last_name VARCHAR(35) NOT NULL); |
Затем используется следующая хранимая процедура для получения информации о пользователях по фамилии пользователя:
CREATE PROCEDURE GetCustomerByLastName @last_name NVARCHAR(35) AS SELECT first_name, last_name FROM Customers WHERE last_name = @last_name; |
Заметьте, что здесь параметр @last_name имеет тип данных NVARCHAR. Код «работает», но SQL Server придется выполнять неявное преобразование колонки фамилий в NVARCHAR, поскольку NVARCHAR – более приоритетный тип данных. Это может привести к потере производительности. Неявное преобразование отображается в плане запроса как CONVERT_IMPLICIT. В зависимости от сортировки и других факторов несоответствие типов данных может также мешать использованию поиска по индексу. Использование корректных типов данных решает проблему:
CREATE PROCEDURE GetCustomerByLastName @last_name VARCHAR(35) AS SELECT first_name, last_name FROM Customers WHERE last_name = @last_name; |
Во многих случаях эта ошибка является результатом распределения ответственности в команде, когда один член команды занимается дизайном таблиц, а другой реализует хранимые процедуры или пишет код. Еще одной причиной может быть соединение данных из разных источников, в которых некоторые колонки могут иметь разные типы данных. Тот же совет применим не только к ошибкам в символьных типах данных, но и к несоответствиям числовых типов данных (например, INT и FLOAT), или к смешению числовых и буквенных типов.
Если вы знакомы с логическим порядком обработки запроса, вы можете ожидать, что запрос выполняется в следующем порядке:
Эта последовательность описывает логический порядок выполнения запроса. Логически выражение FROM обрабатывается первым, определяя исходный набор данных, затем применяются предикаты WHERE, за ними GROUP BY и т.д.
Однако физически запросы обрабатываются не так, и оптимизатор запроса может свободно перемещать выражения в плане запроса, чтобы добиться наиболее эффективного по цене плана выборки данных. Это ведет к распространенному заблуждению, что фильтр в выражении WHERE применяется до обработки следующей фазы. В реальности в физическом плане запроса предикат может быть применен гораздо позже. Кроме того, для выполнения предикатов нет порядка слева направо. Например, если у вас есть выражение WHERE, содержащее "WHERE x=1 AND y=2", нет никакой гарантии, что "x=1" будет рассматриваться первым. Они могут исполняться в любом порядке.
Рассмотрим, например, следующую таблицу Accounts, где в колонке account_reference, Business-счетам соответствует числовая ссылка, а Personal-счетам – буквенная.
account_nbr account_type account_reference ----------- --------------- ----------------- 1 Personal abc 2 Business Basic 101 3 Personal def 4 Business Plus 5 |
В общем, это таблица с плохим дизайном. Колонка account_reference должна быть представлена как два разных атрибута, отдельно для Business- и Personal-счетов, каждый корректного типа данных (и даже не принадлежащих к этой таблице). Однако на практике часто приходится иметь дело с неудачно спроектированными системами, дизайн которых мы не можем изменить.
В данном сценарии корректен запрос всех счетов business-типа со значением ссылки, большим 20 (предполагая, что ссылка на счет имеет какое-то осмысленное числовое значение). Запрос может выглядеть так:
SELECT account_nbr, account_reference AS account_ref_nbr FROM Accounts WHERE account_type LIKE 'Business%' AND CAST(account_reference AS INT) > 20; |
Однако он возвращает ошибку:
"Conversion failed when converting the varchar value 'abc' to data type int" |
Этот запрос не проходит потому, что, как отмечено выше, не существует предопределенного порядка исполнения предикатов, и ничто не гарантирует, что предикат "account_type LIKE ‘Business%’" будет обрабатываться раньше, чем предикат "CAST(account_reference AS INT) > 20". В нашем случае второй предикат рассматривается раньше, что приводит к ошибке преобразования из-за несовместимости значений перcональных счетов в колонке account_reference.
Можно попытаться решить эту проблему с помощью производной таблицы (или табличного выражения, CTE), куда сперва отфильтровываются все бизнес-счета, а затем к ним применяется предикат:
SELECT account_nbr, account_ref_nbr FROM (SELECT account_nbr, CAST(account_reference AS INT) AS account_ref_nbr FROM Accounts WHERE account_type LIKE 'Business%') AS A WHERE account_ref_nbr > 20; |
Однако это приведет к точно той же ошибке, поскольку производные таблицы и СТЕ разворачиваются в плане запроса и создается единый план запроса, где предикаты опять же могут перемещаться вниз и вверх.
Как говорилось выше, проблема состоит в плохом дизайне и непонимании того, как SQL Server выполняет запрос физически. В чем же решение? Лучшее решение – корректно спроектировать таблицу и исключить хранение смешанных данных в одной колонке. В данном же случае можно использовать выражение CASE, чтобы гарантировать, что в тип данных INT будут конвертироваться только числовые значения.
SELECT account_nbr, account_reference AS account_ref_nbr FROM Accounts WHERE account_type LIKE 'Business%' AND CASE WHEN account_reference NOT LIKE '%[^0-9]%' THEN CAST(account_reference AS INT) END > 20; |
Выражение CASE использует паттерн LIKE для проверки корректности числовых значений (используется логика двойного отрицания, которую можно описать так: «здесь нет ни одного символа, не являющегося цифрой»), и выполняет CAST только для этих значений. Для остальных значений выражение CASE возвращает NULL, который отфильтровывается, потому что NULL не совпадает ни с одним значением (даже с NULL).
Внешние соединения – замечательное средство, но во многом не понимаемое и недооцениваемое. Некоторые, похоже, настолько любят их, что стараются запихнуть почти в каждый запрос, независимо от того, нужно ли это!
Ключом к корректному использованию внешних соединений является понимание логических шагов выполнения внешнего соединения в запросе. Вот соответствующие шаги:
Запрос с внешним соединением может принести совершенно разные результаты в зависимости от того, как вы его напишете, и от того, где в этом запросе размещены предикаты. Давайте посмотрим на пример, построенный на двух следующих таблицах, Customers и Orders:
Таблица Customers:
customer_nbr customer_name ------------ -------------- 1 Jim Brown 2 Jeff Gordon 3 Peter Green 4 Julie Peters |
order_nbr order_date customer_nbr order_amt ----------- ---------- ------------ ---------- 1 2008-10-01 1 15.50 2 2008-12-15 2 25.00 3 2009-01-02 1 18.00 4 2009-02-20 3 10.25 5 2009-03-05 1 30.00 |
Задача в том, чтобы получить список всех покупателей и общую стоимость заказов с начала 2009 года. Инстинктивно можно написать такой запрос:
SELECT C.customer_name, SUM(COALESCE(O.order_amt, 0)) AS total_2009 FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr WHERE O.order_date >= '20090101' GROUP BY C.customer_name; |
Но результаты выглядят неутешительно:
customer_name total_2009 -------------- ------------ Jim Brown 48.00 Peter Green 10.25 |
Покупатели Jeff и Julie отсутствуют в результатах. В чем проблема? Чтобы понять, что пошло не так, давайте воспроизведем запрос по шагам, следуя логическому порядку его обработки. Первый шаг – перекрестное соединение двух исходных таблиц:
SELECT C.customer_name, O.order_amt FROM Customers AS C CROSS JOIN Orders AS O; |
Результат – все возможные комбинации строк из обеих таблиц:
customer_name order_amt order_date ---------------- ---------- ---------- Jim Brown 15.50 2008-10-01 Jim Brown 25.00 2008-12-15 Jim Brown 18.00 2009-01-02 Jim Brown 10.25 2009-02-20 Jim Brown 30.00 2009-03-05 Jeff Gordon 15.50 2008-10-01 Jeff Gordon 25.00 2008-12-15 Jeff Gordon 18.00 2009-01-02 Jeff Gordon 10.25 2009-02-20 Jeff Gordon 30.00 2009-03-05 Peter Green 15.50 2008-10-01 Peter Green 25.00 2008-12-15 Peter Green 18.00 2009-01-02 Peter Green 10.25 2009-02-20 Peter Green 30.00 2009-03-05 Julie Peters 15.50 2008-10-01 Julie Peters 25.00 2008-12-15 Julie Peters 18.00 2009-01-02 Julie Peters 10.25 2009-02-20 Julie Peters 30.00 2009-03-05 |
Следующий шаг – применение предикатов ON оператора JOIN.
SELECT C.customer_name, O.order_amt, O.order_date FROM Customers AS C INNER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr; |
Результат запроса содержит только покупателей, делавших заказы. Поскольку покупатель Julie не делал заказов, он исключен из набора результатов:
customer_name order_amt order_date -------------- ---------- ---------- Jim Brown 15.50 2008-10-01 Jeff Gordon 25.00 2008-12-15 Jim Brown 18.00 2009-01-02 Peter Green 10.25 2009-02-20 Jim Brown 30.00 2009-03-05 |
Третий шаг логической обработки запроса – возврат Outer-строк. Эти строки были исключены на предыдущем шаге, поскольку не отвечают предикатам JOIN.
SELECT C.customer_name, O.order_amt, O.order_date FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr; |
Сейчас покупатель Julie возвращается в набор результатов. Заметьте, что внешние строки из сохраняемой таблицы (Customers) имеют значения выбранных атрибутов (customer_name), а строки не сохраняемой таблицы (Orders) содержат NULL вместо значений атрибутов (order_amt и order_date):
customer_name order_amt order_date -------------- ---------- ---------- Jim Brown 15.50 2008-10-01 Jim Brown 18.00 2009-01-02 Jim Brown 30.00 2009-03-05 Jeff Gordon 25.00 2008-12-15 Peter Green 10.25 2009-02-20 Julie Peters NULL NULL |
Последний шаг – применение предикатов оператора WHERE:
SELECT C.customer_name, O.order_amt, O.order_date FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr WHERE O.order_date >= '20090101'; |
Теперь картина ясна! Виноваты предикаты оператора WHERE. Покупатель Jeff удален из результатов запроса, потому что он не делал заказов после 1 января 2009 года, а покупатель Julie – потому что у нее вовсе не было заказов (поскольку строка для Julie содержит NULL в колонке order_date). В сущности, в данном случае предикат оператора WHERE превращает OUTER-соединение в INNER.
Чтобы исправить исходный запрос, достаточно переместить предикат WHERE в условие соединения.
SELECT C.customer_name, SUM(COALESCE(O.order_amt, 0)) AS total_2009 FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr AND O.order_date >= '20090101' GROUP BY C.customer_name; |
Теперь запрос выдаст правильный результат, поскольку Jeff и Julie отфильтровываются в предикатах JOIN, но затем возвращаются обратно, когда возвращаются внешние строки.
customer_name total_2009 -------------- ------------ Jeff Gordon 0.00 Jim Brown 48.00 Julie Peters 0.00 Peter Green 10.25 |
В более сложном примере с множественными соединениями некорректная фильтрация может произойти в последующих табличных операторах (например, соединения с другой таблицей), а не в операторе WHERE. Например, допустим, что у нас есть таблица OrderDetails, содержащая SKU продукта и количество. Запрос должен выдать список всех покупателей, с объемом заказа и количеством, для выбранных SKU. Следующий запрос кажется корректным:
SELECT C.customer_name, O.order_amt, D.qty FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr INNER JOIN OrderDetails AS D ON D.order_nbr = O.order_nbr AND D.sku = 101; |
Однако здесь INNER JOIN с таблицей OrderDetails играет точно ту же роль, что и оператор WHERE в предыдущем примере, в сущности, превращая LEFT OUTER JOIN в INNER JOIN. Корректный запрос, удовлетворяющий требованиям, должен использовать LEFT JOIN для соединения с таблицей OrderDetails:
SELECT C.customer_name, O.order_amt, D.qty FROM Customers AS C LEFT OUTER JOIN Orders AS O ON C.customer_nbr = O.customer_nbr LEFT JOIN OrderDetails AS D ON D.order_nbr = O.order_nbr AND D.sku = 101; |
Очень часто требуется получить значение на основании некоторой корреляции с основной таблицей запроса. Например, рассмотрим следующие две таблицы, хранящие информацию о продуктах и заводах, производящие эти продукты.
Таблица Products:
sku product_description ----- ------------------ 1 Bike 2 Ball 3 Phone |
sku plant_nbr ----- ----------- 1 1 2 1 3 2 |
Требуется извлечь завод-производитель для каждого продукта. Один из способов сделать это – написать следующий запрос, используя для получения завода связанный подзапрос:
SELECT sku, product_description, (SELECT plant_nbr FROM ProductPlants AS B WHERE B.sku = A.sku) AS plant_nbr FROM Products AS A; |
Смысл здесь в том, чтобы просто показать технику; может существовать и более эффективный способ сделать то же самое. Однако все прекрасно работает и мы получаем корректный набор результатов:
sku product_description plant_nbr ---- ------------------- ----------- 1 Bike 1 2 Ball 1 3 Phone 2 |
Запрос будет прекрасно работать до того дня, когда компания решит делать мячи на заводе 3, чтобы справится с растущим спросом. Таблица ProductPlants теперь выглядит так:
sku plant_nbr ----- ----------- 1 1 2 1 2 3 3 2 |
Совершенно неожиданно наш запрос начнет выдавать следующую ошибку:
Msg 512, Level 16, State 1, Line 1 |
Сообщение об ошибке достаточно информативно. Вместо ожидаемого скалярного результата наш подзапрос выдает набор результатов. При имеющихся требованиях исправить это несложно. Чтобы перечислить все заводы, выпускающие конкретный продукт, нужно использовать JOIN:
SELECT A.sku, A.product_description, B.plant_nbr FROM Products AS A JOIN ProductPlants AS B ON A.sku = B.sku; |
Теперь запрос завершается без ошибок и возвращает конкретный результат:
sku product_description plant_nbr ---- -------------------- ----------- 1 Bike 1 2 Ball 1 2 Ball 3 3 Phone 2 |
Заметьте, что та же ошибка может случиться в предикате, где колонка или выражение сравнивается с подзапросом, например, "… column = (SELECT value FROM Table)". В этом случае решение состоит в использовании предиката IN вместо "=".
При первом знакомстве с SQL мы всегда превозносим того гения, который изобрел синтаксис SELECT *! Это так удобно и так просто использовать! Вместо явного перечисления всех имен колонок в запросе, достаточно использовать магический подстановочный символ «*» и получить все колонки. Вот, например, распространенное неверное использование SELECT * – извлечение всех пластиковых продуктов для вставки в другую таблицу с той же структурой:
INSERT INTO PlasticProducts SELECT * FROM Products WHERE material_type = 'plastic'; |
Все готово! Однако однажды требования изменятся и в таблице Products появятся две новых колонки:
ALTER TABLE Products ADD effective_start_date DATETIME, effective_end_date DATETIME; |
Магический запрос неожиданно выдаст ошибку:
Msg 213, Level 16, State 1, Line 1 Insert Error: Column name or number of supplied values does not match table definition. |
Исправление состоит в явном перечислении имен колонок в запросе:
INSERT INTO PlasticProducts (sku, product_description, material_type) SELECT sku, product_description, material_type FROM Products WHERE material_type = 'plastic'; |
Ситуация может быть еще хуже, если с использованием SELECT * создается представление, а позже в базовые таблицы добавляются (или удаляются) колонки.
ПРИМЕЧАНИЕ Примечание: Если представление создается с использованием опции SCHEMABINDING, то базовые таблицы не могут измениться так, чтобы повлиять на определение представления. |
Чтобы завершить, не используйте SELECT * в рабочем коде! Исключением может быть использование предиката EXISTS. Список выборки в подзапросе для предиката EXISTS игнорируется, поскольку важно только наличие строк.
Многократное использование кода – один из фундаментальных принципов, которым мы обучаемся при программировании на любом языке, и SQL – не исключение. Он предоставляет много средств, помогающих логически группировать код и многократно использовать его.
Одно из таких средств в SQL Server – скалярные пользовательские функции. Кажется очень удобным спрятать все сложные вычисления в функции, а затем просто вызывать ее в запросах. Однако скрытый недостаток состоит в том, что это может привести к существенным потерям производительности. При использовании в запросе скалярные функции вычисляются для каждой строки. При больших таблицах это может привести к очень медленно исполняющимся запросам. Особенно это верно, когда скалярной функции нужно обращаться за данными к другой таблице.
Вот один пример. Даны таблицы с продуктами и их продажами, требуется получить общие продажи для каждого продукта. Поскольку значение суммы продаж может быть использовано в других местах, вы решаете использовать скалярную функцию для вычисления общей суммы продаж для продукта:
CREATE FUNCTION dbo.GetTotalSales(@sku INT) RETURNS DECIMAL(15, 2) AS BEGIN RETURN(SELECT SUM(sale_amount) FROM Sales WHERE sku = @sku); END |
Теперь запрос для вычисления общей суммы продаж для каждого продукта выглядит так:
SELECT sku, product_description, dbo.GetTotalSales(sku) AS total_sales FROM Products; |
Не правда ли, это весьма ясный и хорошо выглядящий запрос? Но подождите до момента, когда вы запустите его для большого набора данных. Вычисление общей суммы продаж будет повторяться для каждой строки в отдельности, и накладные расходы будут пропорциональны числу строк. Правильный способ справиться с этим – переписать, если это возможно, функцию в табличную функцию, или просто выполнять расчеты в основном запросе. В нашем примере выполнение вычислений в запросе выглядит так:
SELECT P.sku, P.product_description, SUM(S.sale_amount) As total_sales FROM Products AS P JOIN Sales AS S ON P.sku = S.sku GROUP BY P.sku, P.product_description; |
А вот табличная функция, которую можно использовать для вычисления суммы продаж:
CREATE FUNCTION dbo.GetTotalSales(@sku INT) RETURNS TABLE AS RETURN(SELECT SUM(sale_amount) AS total_sales FROM Sales WHERE sku = @sku); |
Табличную функцию можно вызывать в запросе с помощью оператора APPLY:
SELECT sku, product_description, total_sales FROM Products AS P CROSS APPLY dbo.GetTotalSales(P.sku) AS S; |
Давайте скажем, не стесняясь – мы любим циклы! Когда мы начинаем программировать на VB, C, C++, Java или C#, одна из первых встречающихся нам конструкций – какая-то из форм цикла. Они могут удачно решать почти любую проблему, которая может встретиться.
Итак, это только естественно, что в день, когда мы начинаем программировать на SQL, мы начинаем искать нашу любимую конструкцию цикла. И находим – это могучий курсор (и отчасти его младший брат WHILE)! Найдя же, мы спешим использовать знакомый инструмент для решения проблем.
Посмотрим на один пример. Дана таблица с ценами продуктов, нужно выполнить ежемесячное обновление цен; новые цены хранятся в другой таблице.
Таблица ProductPrices:
sku price effective_start_date effective_end_date ---- ------ -------------------- ------------------ 1 10.50 2009-01-01 NULL 2 11.50 2009-01-01 NULL 3 19.00 2009-01-01 NULL 4 11.25 2009-01-01 NULL |
sku price ---- ------ 2 11.25 4 12.00 |
Решение с курсором может выглядеть так:
DECLARE @sku INT; DECLARE @price DECIMAL(15, 2); DECLARE PriceUpdates CURSOR LOCAL FORWARD_ONLY STATIC READ_ONLY FOR SELECT sku, price FROM NewPrices; OPEN PriceUpdates; FETCH NEXT FROM PriceUpdates INTO @sku, @price; WHILE @@FETCH_STATUS = 0 BEGIN UPDATE ProductPrices SET price = @price, effective_start_date = CURRENT_TIMESTAMP WHERE sku = @sku; FETCH NEXT FROM PriceUpdates INTO @sku, @price; END CLOSE PriceUpdates; DEALLOCATE PriceUpdates; |
Миссия закончена! Можно сделать заслуженный перерыв, пока исполняется запрос. Вскоре приходит понимание, что в SQL процедурная обработка строки за строкой не очень хорошо работает. Кроме того, что это очень медленно, наше решение длинное, его трудно читать и поддерживать. В этот момент мы понимаем, что сила SQL – в его работе с наборами данных. Ту же задачу можно решить, используя очень эффективный запрос, который проще понять и поддерживать:
UPDATE ProductPrices SET price = (SELECT N.price FROM NewPrices AS N WHERE N.sku = ProductPrices.sku), effective_start_date = CURRENT_TIMESTAMP WHERE EXISTS(SELECT * FROM NewPrices AS N WHERE N.sku = ProductPrices.sku); |
Есть разные способы написать работающий с наборами данных запрос, решающий данную проблему: использовать выражение MERGE, обновить данные с помощью Common Table Expression, или использовать характерное для SQL Server обновление через JOIN. Суть в использовании сильных сторон языка SQL и техники работы с наборами данных, и том, чтобы избегать процедурного подхода.
ПРИМЕЧАНИЕ Примечание: хотя курсоров и надо избегать по мере возможности, есть проблемы, например, выполнение полного агрегирования, которые сейчас по-прежнему лучше всего решаются с помощью курсоров. Но можно с оптимизмом сказать, что в будущем появятся более удачные средства, которые позволят решать такие проблемы путем работы с наборами данных. |
Возможно, сейчас как раз пора провести пересмотр вашего SQL-кода. Надеюсь, что некоторые из этих примеров помогут вам стать лучшим разработчиком/DBA, и писать более надежный и эффективный код. Отследить эти паттерны не всегда просто, особенно в более сложных запросах. Но если вы будете понимать основы, избавляться от таких ошибок станет несложно.
Copyright 1994-2016 "-"