はじめに
表題の通り、ミックさんの「SQL緊急救命室」を拝読しました。
恐縮ですが簡単にご紹介すると、テーマが「楽しく学ぶSQL中級入門」とあり、SQLの基礎は習得済みでもう一段階レベルを上げたい人を対象に、会話形式でテンポよく読み進められる作りとなっています。
同著者の作品は他にもあり(達人に学ぶシリーズは特に有名かと存じますが)、こちらのガイドマップを参考に、今の自分に最もしっくりきた「SQL緊急救命室」を読み始めた次第です。
感想
結論、めちゃくちゃ面白かったです。
後ほど記載しますが、ちょうど読んだ内容を実務で活かす機会があったこともあり、感動体験を得ることができ大変満足でした。
CASE式とウインドウ関数
本書で扱う内容には大きく2つの要素があり、それは「CASE式」と「ウインドウ関数」です。
私自身、どちらも元々知ってはいて使ったことがあるものの、本書で改めて学ぶことで、いかに強力な機能かを思い知らされました。
CASE式については、「こんなに色んなとこで使っていいんだ」と驚き、HAVING句との組み合わせは、その強力さに感動しました。
ウインドウ関数については、これまで私はコピペで、しかも機能のごく一部を使えていただけということを痛感することになりました。
本書でウインドウ関数の様々な機能を知り、また体系だって使い方を習得したことで、今では「SQLで何でもできるじゃん」という自信を与えてくれています。
これらの強力な武器を手に入れたことで、手続き型言語における構造化プログラミングのように、SQLでも実現できることが格段に増えたことが、本書を読んで得られた最も大きな価値だと感じています。
SQLは宣言型言語
もう一つ、自分の中で大きな価値だと感じた点は、「宣言型の世界」を感じられたことです。
普段、C#などのいわゆる手続き型言語でコーディングする際は、「こうして、こうして、こうすればよさそうだ」と考えますが、SQLのような宣言型言語では「こうなればよさそうだ」と考えるイメージがあります。
以前から、何となくその違いを感じつつも輪郭をつかめずにいましたが、本書の「住んでいる世界が違う」という表現が、私の持っていたぼんやりしたイメージにビタっとはまり、SQLとの向き合い方が以前よりクリアになった印象があります。
具体的に何かが変わったわけではないのですが、この視界が開けたような感覚は、とても価値があるものだと感じました。
実践例
本書で学んだ内容を実際に実務で活かすことができたので、その時のことを簡単にメモしておこうと思います。
動作環境
- Windows 11 Home(24H2)
- C# 12.0
- .NET 8.0
- Entity Framework Core 9.0.8
- SQL Server 2022
概要
前提として、上記のような月次状況テーブル(MonthlyStatuses)が用意されています。
具体的な部分は端折っていますが、各年月においてFix済みかを管理しているイメージです。
年月 | 金額 |
---|---|
202509 | 2000 |
202508 | 3000 |
202508 | 1500 |
202507 | 2500 |
202507 | 3000 |
202506 | 1000 |
その上で、上記のようなデータ(「金額データ」と呼びます)に対して、Fix済みの年月であるレコードが含まれているか、含まれている場合はその年月の一覧を取得する必要がありました。
実装
// 金額データクラス
public class AmountData
{
public string YearMonth { get; set; } = string.Empty;
public int Amount { get; set; }
}
public async Task<(bool result, List<string> fixedYMList)> IsFixedYMExistsAsync(List<AmountData> amountDataList)
{
// Fix済みのレコードが含まれない場合、true, 空のリストを返却
// Fix済みのレコードが含まれる場合、false, 該当のYearMonthのリストを返却
}
×悪い例
public async Task<(bool result, List<string> fixedYMList)> IsFixedYMExistsAsync(List<AmountData> amountDataList)
{
bool result = true;
List<string> fixedYMList = new();
// 判定対象の年月リストを作成
List<string> targetYMList = amountDataList.Select(x => x.YearMonth).Distinct().ToList();
foreach (string yearMonth in targetYMList)
{
bool isFixed = await IsYearMonthFixedAsync(yearMonth);
// Fix済みの年月の場合リストに追加
if (isFixed) fixedYMList.Add(yearMonth);
}
if (fixedYMList.Count != 0) result = false;
return (result, fixedYMList);
}
private async Task<bool> IsYearMonthFixedAsync(string yearMonth)
{
bool isFixed = await _context.MonthlyStatuses
.Where(x => x.YearMonth.Equals(yearMonth))
.Select(x => x.IsFixed)
.FirstOrDefaultAsync();
return isFixed;
}
実装にあたり、初めに思いついた(思いついてしまった)コードは上記の通りです。
金額データから判定対象の年月リストを作成し、それらをループして1件ずつFix済みかを確認しています。
この実装でも意図した通り動作はします。
が、本書で学んだ内容と照らし合わせると、判定対象のレコード数だけデータアクセスが発生することから、ストレージに対するI/Oを余計に増やすことになり、パフォーマンスへの影響が懸念されます。
このように、とりあえず手続き型言語側で構造化プログラミングをしてしまう考え方は、本書で紹介されている「ループ依存症」と根本が似ていると感じました。
○改善例
public async Task<(bool result, List<string> fixedYMList)> IsFixedYMExistsAsync(List<AmountData> amountDataList)
{
bool result = true;
List<string> fixedYMList = new();
// 判定対象の年月リストを作成
List<string> targetYMList = amountDataList.Select(x => x.YearMonth).Distinct().ToList();
+ // Fix済みの年月を取得しリストに追加
+ fixedYMList = await _context.MonthlyStatuses
+ .Where(x => x.IsFixed && targetYMList.Contains(x.YearMonth))
+ .Select(x => x.YearMonth)
+ .ToListAsync();
if (fixedYMList.Count != 0) result = false;
return (result, fixedYMList);
}
先ほどと異なり、判定対象の年月リスト自体を条件に使用し、Fix済みの年月を取得するようにしています。
これにより、判定対象のレコード数が増えたとしても、データアクセスは1回で済むようになりました。
評価
上記2つの例におけるパフォーマンスを比べてみます。
悪い例
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (211ms) [Parameters=[@__yearMonth_0='202509' (Size = 6)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [m].[IsFixed]
FROM [MonthlyStatuses] AS [m]
WHERE [m].[YearMonth] = @__yearMonth_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (8ms) [Parameters=[@__yearMonth_0='202508' (Size = 6)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [m].[IsFixed]
FROM [MonthlyStatuses] AS [m]
WHERE [m].[YearMonth] = @__yearMonth_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@__yearMonth_0='202507' (Size = 6)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [m].[IsFixed]
FROM [MonthlyStatuses] AS [m]
WHERE [m].[YearMonth] = @__yearMonth_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__yearMonth_0='202506' (Size = 6)], CommandType='Text', CommandTimeout='30']
SELECT TOP(1) [m].[IsFixed]
FROM [MonthlyStatuses] AS [m]
WHERE [m].[YearMonth] = @__yearMonth_0
211+8+2+1と、合計222ミリ秒かかっています。
特筆すべきは、クエリが4回に分けて実行されている点です。
改善例
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (268ms) [Parameters=[@__targetYMList_0='["202509","202508","202507","202506"]' (Size = 4000)], CommandType='Text', CommandTimeout='30']
SELECT [m].[YearMonth]
FROM [MonthlyStatuses] AS [m]
WHERE [m].[IsFixed] = CAST(1 AS bit) AND [m].[YearMonth] IN (
SELECT [t].[value]
FROM OPENJSON(@__targetYMList_0) WITH ([value] nvarchar(6) '$') AS [t]
)
こちらは単一クエリとなり、268ミリ秒かかっています。
今回の例で比較すると「悪い例」の方が実行時間が短くなるようです…
しかし、複数回発生するデータアクセスに伴うオーバーヘッドや、対象のレコード数が増えた時のことを考えると、「改善例」のクエリの方が望ましいとされるのかな??と考えました。
振り返り
(「改善例」を正と仮定して話を進めると、、)
2つの例を見比べると、「これが実践例だ!」と挙げるほど大層なケースではありませんでした。
ウインドウ関数やCASE式を使うわけでもなく、実行するクエリ自体も大変初歩的なものだからです。
そのためいざ振り返ると、さすがに「悪い例」のようなプログラミングなんてしないか?と感じますが、パッと思いついてしまったのは事実です。
ではなぜパッと思いついたかを分析すると、今回実現したかった処理がアプリケーション起点で考えるものだったからではないかと思います。
つまり、思考のスタートがアプリケーション側で保持しているデータだったことから、データを加工したり処理の順番を制御しやすいという手続き型言語の誘惑にまんまと駆られたと考えられます。
その点、本書を通して、ビジネスロジックをSQLに委譲するメリットや、宣言型言語の世界に触れる経験をしていたので、誘惑に駆られてしまっている自分に気づき修正することができました。
本書でも紹介されている、E.F.コッド氏の言う「SQLの目的はループをなくすこと」、この意味や効果を少しでも体感できたのではないかと考えています。
今回の経験から、特別エレガントなクエリを扱ったわけではないものの、パフォーマンスに対する意識や、手続き型・宣言型の世界をイメージしたプログラミング能力が以前より身についたのではと感じました。