取りかかれるのはだいぶ先だと思うけど。
現行のSQLは重大な問題をいくつも抱えている。
だから、新しい文法のSQLが必要だ。
ちょうどDartが新しい文法でJSの世界を救ったように、SQLにも救世主が必要だ。
RDBの背景には集合論を土台にした素晴らしいセマンティクスがある。
しかし、SQLの文法が悪いせいでRDBが扱いにくいものだと思われている。
よくあるイメージとして、これは間違った考えだが、KVSはRDBよりかんたんに扱えると思っている人がいる。
この状況を放置すれば人類は安直にKVSに流されてしまう。
また、RDBは速度に限界があると言われている。
いくつか話題になりやすい理由を上げる
- SQLのパースが遅い
- JOINが遅い
- ロックが遅い
- 挿入時の整合性確認が遅い
1はバイナリフォーマットを定義するとことで回避するべきだ。
2〜4はシステムの安全性とトレードオフだ。
RDBが正規化・トランザクション・整合性確認(参照整合性+チェック制約)という機能を持っているから安全なアプリケーションが作れる。
しかし、速度のためこの機能をあえて使わない選択がされてしまうことがある。
もっと速度を出しやすい文法にするべきだ。
速度を出しやすい文法とはどういうことだろうか。
RDBの低レベルのAPIを露出させてプログラマが最適化すれば速くなる。
しかし、それはやめたほうがいい。
逆に、プログラマはより抽象度の高いクエリを書くべきだ。
RDBMSによりたくさんの意図を伝えることで、RDBMS側が自動で最適化できるようにするべきだ。
実現方法として、トランスパイルすると標準のSQLに変換できる文法を新しく設計する。
解決されるべき課題
TABLEに名前空間がない
RDBではグローバル名前空間に全てのテーブル名がぶら下がっている。
しかし、別のテーブルとJOINせずにSELECTすることはほぼないと言い切れるテーブルもある。
実際のシステムでは半数近くがこのような二級市民テーブルだろう。
僕はMySQL WorkbenchやIntelliJのSQL編集機能といったSQL専用エディタで開発をする。
専用エディタは入力中にコード補完をしてくれる。
FROM句やJOINキーワードを書こうとすると候補を上げてくれるのだ。
ところが、大きなシステムでは候補が大量に出てきてあまり意味がないことがある。
「このTABLEとJOINする可能性があるのはこのTABLEに決まっているのではないか」「トップレベルのFROM句にこのTABLEが出てくることはないのではないか」と思うことがある。
そこで、大量のテーブルを分類して名前空間を分けることができるべきだ。
そしてprivate句とpublic句を定義できるようにしたい。
フィールドに名前空間がない
UserテーブルやProductテーブルに、ものすごくたくさん列が存在するシステムに覚えはないだろうか。
フィールドを分類して垂直分割をすることも考える。
しかし、それはパフォーマンスの問題につながる。
そもそも垂直分割はアンチパターンということになっていると思う。
もっと簡単に列をネストできるべきだ。
例えばGo言語で
type User struct {
fieldA int
fieldB int
fieldC int
fieldD int
...
}
と長くなってきてしまったら
type User struct{
subTypeA SubTypeA
subTypeB SubTypeB
}
type SubTypeA struct {
fieldA int
fieldB int
}
type SubTypeB struct {
fieldC int
fieldD int
}
のように分割する。 同じようなことができてほしい。
これをRDBで扱う時はのような意味に変換するか?
もちろん、「構造体を保存できるようになりました」という話ではない。
実際は並列にカラムが並んでいるが、その中で名前空間を表現できるシンタックスシュガー、という意味に解釈する。
各フィールドは木構造を直列に変換して表現する。
オブジェクトの階層の区切り文字「.」を別の文字に置き換える。
// 保存したい値
USER.BODY.HEIGHT = 170
USER.BODY.WEIGHT = 60
USER.MENTAL.COURAGE = 5
USER.MENTAL.CALMNESS = 10
// 新SQL
CREATE TABLE USER(
BODY (
HEIGHT INT,
WEIGHT INT,
),
MENTAL (
COURAGE INT,
CALMNESS INT,
),
);
// トランスパイルされたSQL
CREATE TABLE USER (
BODY__HEIGHT INT,
BODY__WEIGHT INT,
MENTAL__COURAGE INT,
MENTAL__CALMNESS INT,
);
// 取り出し
SELECT BODY.* FROM USERS;
親子関係を直接表現する方法がない
一般にTABLEの親子関係を作るにはCREATE TABLE文内でFOREIGN KEYを使い、クエリ時にJOINする。
これをもっと簡易に書きたい。
それができないせいで配列を保存できるようなRDBが出てきてしまうと思う。
親TABLEが一つの場合
一旦複数の親を持つTABLEのことは置いておこう。複雑だ。
自分が考えたのはこのような文法だ。
CREATE TABLE USERS (
ID INT,
NAME VARCHAR
);
CREATE TABLE USERS.FAVORITE_FOODS (USERS.ID AS USER_ID) (
USER_ID,
FOOD_NAME VARCHAR
);
まずCREATE TABLE USERS.FAVORITE_FOODSで、FAVORITE_FOODSはUSERSにぶら下がっているということを明示している。
さらに(USERS.ID AS USER_ID)でUSERS.IDはFAVORITE_FOODSのUSER_IDと同じだと宣言している。
これによりFOREIGN KEY文よりわかりやすく親子関係を表現している。
クエリはこのようになる。
カレーが好きな人一覧なら
SELECT * FROM USERS WHERE USERS.FAVORITE_FOODS IN "カレー";
のような感じになるだろう。
このあたりは、相関クエリのためにサブクエリを省略できる文法として設計するか、
集計関数のためにFROM・WHERE句を省略できる文法として設計するかちょっと悩んでいる。
親TABLEが2つある場合
親TABLEが2つある場合、上記の文法では対応できない。
この場合、そもそも2つのテーブルをJOINした結果のTABLEを定義できるようにし、
そのTABLEに上の文法で子供を作れるようにする。
ユーザー一覧のUSERSと食べ物一覧のFOODSがあり、各ユーザーの好きな食べ物をFAVORITE_FOODSに保存する。
CREATE JOINED TABLE USERS, FOODS AS USERS_FOODS;
CREATE TABLE USERS_FOODS.FAVORITE_FOODS (USERS_FOODS.USER.ID as USER_ID , USERS_FOODS.FOODS.ID as FOOD_ID) (
USER_ID,
FOOD_ID,
);
一見ただのCREATE VIEW文と変わらないが、あえてCREATE JOINED文を定義することに意味がある。
CREATE VIEW文はあらゆることに使えるのでなんのために作ったViewかわからない。
それに対してわざわざCREATE JOINED TABLE文を使っていたら、
この組み合わせに対して子TABLEをぶら下げるためにこの中間テーブルを作ったというメッセージが伝わりやすい。
ダイヤモンドになると最悪
親子関係がダイヤモンド型になる時に、外部キーを正確に書くのは非常に難しい。
フライトテーブルに夕食のメニューテーブルと乗客テーブルがぶら下がり、
その両方にぶら下がる乗客の食事メニューテーブルのことを考えよう。
CREATE TABLE PASSENGERS_FOODS (
PASSENGER_ID int,
FOOD_ID int,
FOREIGN KEY PASSENGER_ID REFERENCES PASSENGERS (PASSENGER_ID),
FOREIGN KEY FOODS_ID REFERENCES FOODS (FOODS_ID)
);
これではだめだ。
このようにうっかりするとフライトAに乗る乗客にフライトBのメニューを設定できるテーブルが出来上がる。
正確にはこうだ。
CREATE TABLE PASSENGERS_FOODS (
FLIGHT_ID int,
PASSENGER_ID int,
FOOD_ID int,
FOREIGN KEY (FLIGHT_ID, PASSENGER_ID) REFERENCES PASSENGERS (FLIGHT_ID, PASSENGER_ID),
FOREIGN KEY (FLIGHT_ID, FOODS_ID) REFERENCES FOODS (FLIGHT_ID, FOODS_ID),
);
この場合CREATE JOIND TABLE文をカスタマイズすればいい。
CREATE JOINED TABLE PASSENGERS, FOODS AS PASSENGERS_FOODS
REPRESENTATIVE KEY (PASSENGERS.FLIGHT_ID, FOODS.FLIGHT_ID) as FLIGHT_ID;
このようにPASSENGERSとFOODSのデカルト積からFLIGHT_IDが一致しない行を取り除いている。
おかげで、このJOINED TABLEに子テーブルをぶら下げれば、必ずFLIGHT_IDの掛け違いが起こらないようになった。
CREATE TABLE PASSENGERS_FOODS.PASSENGERS_FOODS
(PASSENGERS_FOODS.PASSENGERS.PASSENGER_ID as PASSENGER_ID,
PASSENGERS_FOODS.FOODS.FOODS_ID as FOOD_ID) (
PASSENGER_ID,
FOOD_ID,
);
似たようなテーブルを複数作るのがめんどくさい
よく、RDBには継承がないと言われる。
しかし、そのような大掛かりな機能を導入するのは難しい。
欲しいのは他のテーブルのCREATE TABLE文をコピペする構文だ。
JSのスプレッド演算子のようなものがあればいいと思う。
CREATE ABSTRACT TABLE PERSON (
NAME VARCHAR,
AGE INT,
);
CREATE TABLE CUSTOMER (
ID int,
...PERSON,
PHONE VARCHAR,
);
CREATE TABLE EMPLOYEE (
ID int,
...PERSON,
JOB VARCHAR
);
こんな感じだ。
3値論理が複雑
やはりOptional型は必要だと思う。
よく「NULLとFALSEを比較したらどうなるか」などのまとめ表がSQLの教科書に載っている。
ありがたいと思うが、そもそもNULLABLEとそれ以外の値を演算できるのがおかしい。
NULLABLEをunwrapせずに演算しているところをすべてコンパイルエラーにするべきだ。
Java、PHP等のRDBMSドライバもNULLABLEのintはintではなくOptional<Integer>で返すべきだ。
新しく追加されたcase文でこれまでに比べれば便利になったが、しかし依然として文字の量が多いので、
もっと便利な構文がほしい。
RDBにもOptional型を追加し、get(), orelse(), is_empty(), is_present()等の関数を追加するべきだ。
ユーザー定義型
また、ユーザー定義型も欲しい。
しかし、RDBにおいてスカラ値ではなくベクトル値を保存できてしまうのは良くない。
必要なのは、TABLEでは複数の列を使って値を保存するが、SQL上は一つの構造体として扱えるものだろう。
似たようなものを挙げるとC++だ。
C++のクラスはオブジェクトをメモリ上にどのように表現するかプログラマが責任を持って決めないといけない。
同じようにRDBも構造体を保存できる機能を追加するのではなく、SQLでは構造体として扱われる物をTABLE上でどのように表現するかプログラマが責任を持って決めるべきだ。
例えば何型の列が何個いるか、どんなチェック制約が必要かについて決める必要がある。
ついでに代数的データ型もほしい。
WITH句にクエリではなく関数を書きたい
自分はJavaScriptなどで高階関数を書くとき、その式が長くなりすぎるならローカル関数を定義し高階関数の中を簡略化することがある。
例えば
function filterUserByGender(users, gender) {
function genderEqual(user) {
// これならここの式がどれだけ長くなってもストレスがない。
return user.gender == gender;
}
return users.filter( u => genderEqual(u));
}
SQLも同じように書きたい。
複雑な相関クエリをCREATE TEMPORARY TABLE文で簡単にするのはよくあるパターンだ。
クエリによっては大きな効果が得られる。
(TwitterでWITH句を使うように教えていただきました)
しかし、相関の仕方が複雑なとき意味をなさないことがある。
これはある会社の各課で誰が一番若いかを集計するクエリだ。
SELECT
ID , NAME
FROM
EMPLOYEES
WHERE AGE = (SELECT MIN(AGE) FROM EMPLOYEES as E2 WHERE DIVISION = E2.DIVISION);
これを一時テーブルで簡略化してみよう。
CREATE TEMPORARY TABLE DIVISION_MIN_AGE AS
(SELECT DIVISION, MIN(AGE) AS AGE FROM EMPLOYEES GROUP BY DIVISION);
SELECT
ID , NAME
FROM
EMPLOYEES
WHERE AGE = (SELECT AGE FROM DIVISION_MIN_AGE WHERE DIVISION_MIN_AGE.DIVISION = EMPLOYEES.DIVISION);
このように全く意味がない。
なぜ意味がないのか。
このクエリで一番冗長な箇所はサブクエリのWHERE句だ。
「DIVISION_MIN_AGE.DIVISION = EMPLOYEES.DIVISION」
この部分を外に出す方法がないから意味がないのだ。
そこで、WITH句に関数を宣言できるようにしたい。
WITH FUNCTION IS_MIN_AGE(VARCHAR(10) DIVISION, INT AGE) AS (
(SELECT MIN(AGE) FROM EMPLOYEES WHERE EMPLOYEE.DIVISION = DIVISION) == AGE
)
SELECT
ID , NAME
FROM
EMPLOYEES
WHERE AGE = IS_MIN_AGE(ID, AGE);
大変綺麗になったと思う。
木構造でResultが取りたい
これはSQLというよりドライバの問題かもしれない。
あるクエリの結果の行をもとに別のクエリを投げる、ということがある。
アプリケーション側が一回目のクエリの結果を使わないなら、WHERE INを使えば解決する。
しかし、アプリケーション側が一回目のクエリの結果を使うならば、少なくとも二回に分けてクエリを投げなければいけない。
下の表を見てほしい。
この表を出力するにはアプリケーション側で下のような2重For文を書かなければいけない。
先に都道府県一覧を取得し、その結果をもとに市町村一覧を取得する。
疑似コードだがこんな感じであろう。
foreach($pref_row as $connection->fetchAll("SELECT * FROM PREFECTURES")) {
foreach($city_row as $connection->fetchAll("SELECT * FROM CITIES WHERE PREFECTURE_ID = ${pref_row->id}"){
print $pref_row->name, $city_row->name, $city_row->population;
}
}
少し悔しい。
SELECT文が木構造に対応してくれたらいいのだ。
SELECT
PREFECTURE_ID ,
PREFECTURE_NAME,
VISITOR (SELECT * FROM CITIES WHERE PREFECTURES.PREFECTURE_ID = CITIES.PREFECTURE_ID) AS CITIES
FROM PREFECUTRES;
foreach($pref_row as $connection->fetchAll($sql)) {
foreach($city_row as $pref_row->cities){
print $pref_row->name, $city_row->name, $city_row->population;
}
}
綺麗に書けた。
冒頭でAlt SQLはSQLに変換して使うと書いたが、もしRDBMSがこの構文に対応してくれるならパフォーマンスが向上するであろう。
上のコードの場合RDBMSは1回目のクエリ結果をもとに二回目のクエリがやってくるということを知らない。
しかし下のコードであれば、HDDからRAMに読み込んだデータやカーソルを意図的に使いまわすことが可能になる。