Cypherならできる!SQLには難しいこと10選(前編) #neo4j #cypher #sql
この記事は1年以上前に投稿されました。情報が古い可能性がありますので、ご注意ください。
本ブログは、Neo4j社のブログでNeo4j社のMichael Hungerさんが執筆し、2022年5月6日に公開された「10 Things You Can Do With Cypher That Are Hard With SQL」の日本語翻訳です。
こちらは、2021年12月、Neo4j Connections: Graphs for Cloud DevelopersでMichael Hungerさんが行った発表内容を元にしたものです。
SQLは歴史のある強力なクエリ言語ですが、SQLよりCypherのほうが適している場合が多くあります。このブログでは、比較のためSQLについても言及しますが、基本的にはNeo4jのクエリ言語であるCypherに焦点を当てます。
目次
- CypherとSQLの比較
- データセット
- グラフパターンとJOINの比較
- 1. 経路パターン
- 2. 最短経路
- 3. GROUP BYを使用せずに集計
- 4. ビルトインのデータ構造サポート:リストとマップ
- 5. UNWIND と Collect
- 6. マップ・プロジェクションとパターン内包
- 7. ReadとWriteの併用
- 8. トランザクショナル・バッチング
- 9. ビルトインのデータローディング:CSV
- 10. ビルトインのデータローディング: API
- まとめ
(注:「4. ビルトインのデータ構造サポート:リストとマップ」以降は後編にて)
まず、CypherとSQLの比較、例として扱うデータセットの紹介、グラフパターンとJOINの比較をします。
CypherとSQLの比較
クエリ言語としてのSQLは50年以上の歴史があり、多くのベンダーやISO委員会の協力により開発されたものです。当初、SQLは単一テーブル用のクエリ言語として開発されました。その後、JOINに加え、例えば地理空間データのような、XMLやJSONのサポートが追加されました。以下は、小売業のデータベースから売れ筋の商品を選択する典型的なSQLクエリです。
SELECT p.product_name, count(*) as cnt FROM customers c JOIN orders o ON (c.customer_id = o.customer_id) JOIN order_details od on (od.order_id = o.order_id) JOIN products p ON (od.product_id = p.product_id) WHERE p.product_name LIKE '%G%' GROUP BY p.product_name ORDER BY cnt DESC LIMIT 5;
4つのJOINを使用しており、以下のような結果を得ます。
product_name | cnt |
---|---|
Guaraná Fantástica | 51 |
Gorgonzola Telino | 51 |
Gnocchi di nonna Alice | 50 |
Mozzarella di Giovanni | 38 |
Gumbär Gummibärchen | 32 |
リレーショナルデータベースはテーブルで構築され、JOINによってつながりを表現します。
一方、Cypherはグラフのために設計された言語です。比較のため、以下のCypherクエリをご覧ください。
MATCH (a:Person)-[:ACTED_IN]->(m:Movie) WITH a, count(m) as movies WHERE movies > 2 MATCH (a)-[:DIRECTED]->(m) RETURN a.name, collect(m.title) as movies, count(*) as count ORDER BY count DESC LIMIT 5; <サンプルデータセット:Movie>
このクエリには、グラフの構成要素が含まれています。つまり、ノードやリレーションシップのようなエンティティがコードに組み込まれています。Cypherは、開発者でない人にとっても読みやすく、理解しやすいのが特徴です。強力でモダンな言語であり、他のクエリやPythonのようなプログラミング言語から表現を借用している部分もあります。各分野の専門家やビジネスアナリストでも、これらのクエリを読みフィードバックを提供し、さらには自らクエリを作成することもできます。
グラフデータベースの長所としては、データベース検索を行う際にスキーマ※の指定が必須ではないことがあげられます。つまり、データを表すスキーマが固定されておらず、新しい属性タイプやリレーションシップを追加しながら、スキーマデータを進化させることができるのです。そのため、アプリケーション開発に柔軟性をもたらします。
※スキーマとは、データベースの構造を定義したもの。各フィールドのデータ型やデータの大きさ、主キーの選択など。
データセット
本ブログでは、Neo4jに組み込まれている2つのサンプルデータセット(MoviesとNorthwind)を利用して説明します。IMDb(Internet Movie Database)スタイルのデータベースとNorthwindという小売業のデータベースを取り上げた理由は、SQLでも素材がたくさんあるためです。これら2つのデータセットのデータモデルをご覧ください。
上図は、映画のデータベースであり、人は映画とのリレーションシップがあります。映画の監督を務めた、脚本を書いた、製作した、出演した、評論した、などのリレーションシップです。もう1つのデータベースであるNorthwindには、商品、カテゴリ、顧客の注文などがあります。
グラフパターンとJOINの比較
Cypherでは、視覚的なパターンに着目します。ホワイトボードや図に描くような丸や矢印などを、Cypherクエリ言語では表現できます。
ノード
すべてのノードとエンティティは括弧で囲まれています。また、括弧の中にノードの種類や追加の属性などを入れることができます。
()
(:Person)
(p:Person {name:'Emil'})
リレーションシップ
リレーションシップの表現も同様にシンプルです。ダッシュ記号を2つ連ねれば、それだけでリレーションシップを表せます。または、2本のダッシュと大なり記号を組み合わせて矢印にしたり、角括弧を使って追加の情報を挿入できます。
--
-->
-[:RATED]->
-[:RATED {stars:5}]->
ノードとリレーションシップの組み合わせにより、Cypherパターンが出来上がります。
(:Person {name:'Emil'})-[:RATED {stars:5}]->(:Movie {title:'The Matrix'}) <サンプルデータセット:Movie>
ホワイトボードに描かれた「エミル」という人物と、「マトリックス」という映画を指す矢印が目に浮かぶようです。これらの小さな半角ダッシュは、実はリレーショナルデータベースにおけるJOINのようなものなのです。リレーショナルデータベースの場合は、対象エンティティからJOINテーブルへ、さらにJOINテーブルから他のエンティティへJOINする必要があり、計算ステップが増大します。
FROM customers c1 JOIN orders o1 ON (c1.customer_id = o1.customer_id) JOIN order_details od1 ON (od1.order_id = o1.order_id) JOIN products p1 ON (od1.product_id = p1.product_id) JOIN orders o2 ON (o2.customer_id = o1.customer_id) JOIN order_details od2 ON (od2.order_id = o2.order_id) JOIN products p2 ON (od2.product_id = p2.product_id) WHERE c1.customer_name = 'Emil' AND p1.product_name = 'Philz Coffee'
上記の顧客と製品の関係のSQLを使って、レコメンデーションエンジンを構築できるでしょう。ただし、リレーショナルデータベースでこれを実現するには、多くのJOINが必要で、その一つ一つがアプリケーションの速度を低下させます。(ちなみにエミルはフィルツコーヒーが大好物のようですね。)
さて、いくつかのポイントを押さえたところで、SQLでは難しいけれど、Cypherなら簡単にできること10選をご紹介していきます!
1. 経路パターン
最初から高度な機能の紹介になってしまいますが、経路パターンについてご紹介します。
:param product=>"Pavlova";
ここでは、後ほどクエリで使用するproductというパラメータを設定しています。では、パスパターンを見てみましょう。
MATCH path = (p:Product {productName:$product})-[:PART_OF*]->(root:Category) RETURN path <サンプルデータセット: Northwind>
カテゴリのリストを指す製品があります。カテゴリの階層の深さが分からない場合は、アスタリスクを付けておけば、ルートカテゴリにたどり着くまでの任意のリンクしたリレーションシップが検索結果として返されます。
MATCH path = (p:Product {productName:$product})-[:PART_OF*1..10]->(root:Category) RETURN path <サンプルデータセット: Northwind>
最大10個のリレーションシップのパスを見つけるといった制限も可能です。
以下の1行目では「Confections」という1つのカテゴリのみありますが、カテゴリを増やすことも可能です。
MATCH path = (c:Category {categoryName: "Confections"}) CREATE (c-[:PART_OF]->(:Category {categoryName: "Sweets"})-[:PART_OF]->(:Category {categoryName: "Unhealthy Food"}) RETURN path <サンプルデータセット: Northwind>
リレーションシップタイプPART_OFを使用して、categoryNameが「Sweets」のカテゴリを追加します。さらに、categoryNameを「Unhealthy Food」としたカテゴリを追加することも可能です。そうすると、次のようなパスになります。
見ての通り、商品からルートカテゴリまでパスが続いています。SQLで同じことを実現しようとすると、すべての階層でJOINを使わなければなりません。あるいは、共通テーブル式を使うこともできますが、これはテーブルを再帰的に結合する、より複雑な方法に過ぎません。
ループを見つける
パスパターンに関連して、グラフでは、ループを見つけることもできます。例えば、人物Aから始まって、最終的にまた人物Aにつながるループを見つけたい場合などがあります。
MATCH path = (p:Person)-[*1..5]-(p) RETURN [n in nodes(path) | coalesce(n.name, n.title)] as path, length(path) LIMIT 10 <サンプルデータセット: Movies>
ここでは、1~5ホップでスタート地点の人に戻ってくるパスを探しています。このデータを表形式だけでなく、視覚的に示すことができるのがグラフの特徴です。もう少し長めのループのクエリとグラフを見てみましょう。
MATCH path = (p:Person)-[*3..10]-(p) RETURN path LIMIT 1 <サンプルデータセット: Movies>
2. 最短経路
Cypherでできることの2つ目は、最短経路です。LinkedInで「この人とは何ホップ離れている」というような情報を見たことがあるかもしれません。これは、グラフデータベースで簡単にできます。
:param name=>"Meg Ryan";
まず、出発点となる人物から始めますが、今回は「メグ・ライアン」という名前を使用します。メグ・ライアンからケビン・ベーコンへの最短経路を探したいとしましょう。
MATCH path = shortestPath( (:Person {name:$name})-[:ACTED_IN*..10]-(:Person {name:'Kevin Bacon'}) ) RETURN path, length(path); <サンプルデータセット: Movies>
ACTED_INでの連結を最大10ホップまでに設定し、パスとパスの長さを返すようにしています。このパスは1ホップ、2ホップ、3ホップなどと任意の長さに設定できます。
ここでは、メグ・ライアンからケビン・ベーコンまで、4ホップある結果となっています。間に映画を挟んだ、俳優と俳優のリレーションシップです。
3. GROUP BYを使用せずに集計
この機能ができた時、個人的にとても嬉しかったです。SQLではフィールド選択とGROUP BY文の両方でグループ化キーを記述する必要があり、GROUP BYは冗長だといつも感じていました。GROUP BY文または非集計フィールドのグループ化キーのどちらかがあれば充分なので、私含めNeo4jメンバーはCypherを作るにあたって、この冗長な表現を避けたいと思っていました。
MATCH (m:Movie)<-[:ACTED_IN]-(a:Person) // 中間集計、宣言されたフィールドのみを渡す WITH a, count(m) as movies // 集計値にフィルタをかける WHERE movies > 2 // 次のクエリ部分 MATCH (a)-[:DIRECTED]->(m) // 最後にa.nameによる集計 RETURN a.name, collect(m.title) as movies, count(*) as count ORDER BY count DESC LIMIT 5; <サンプルデータセット:Movie>
上記の例では、ある映画に出演した人を探して、中間集計を行います。次に、WITH句を使用して、人別の集計を確認します。自動的にPersonがグループ化キーになります。なぜなら、結果でこのフィールドだけが集約されていないからです。
続きは後編の記事にてご覧いただけます。