SQLライクな「グラフ」クエリエンジンOpen SOQLを作ってみた


本日(2020-08-17)、[Open SOQL](https://github.com/shellyln/open-soql) 最初の安定版となる v0.1.0 をリリースしました🎉

[Open SOQL](https://github.com/shellyln/open-soql)はSalesforceで使われている独自のクエリ言語「[SOQL](https://developer.salesforce.com/docs/atlas.ja-jp.soql_sosl.meta/soql_sosl/sforce_api_calls_soql.htm)」(Salesforce Object Query Language) のオープンソース実装版です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185253/39453331-d8b9-7bdf-d0a9-e47d29477889.png" width="350px">

オリジナルのSOQLは長い歴史を持ちますが (詳しい歴史を辿れないのですが、2008年には既に存在していたようです)、オブジェクトの**グラフ**を利用者が必要な項目に絞って取得できる (つまり、オーバークエリしない) という、まるで[GraphQL](https://graphql.org/)のような特徴を持っています。
(GraphQLの初版は2015年のようです)

文法もSQLに近く学習コストが抑えられており、さらに`Group by`による集計もサポートしています。

[Open SOQL](https://github.com/shellyln/open-soql)はオリジナルのSOQLとは異なり、Salesforceのデータをクエリするものでは**ありません**。[GraphQL](https://graphql.org/)の[Apollo Server](https://github.com/apollographql/apollo-server)等と同様に、ノードに対応するリゾルバ (コールバック関数) にデータの取得・更新を移譲します。これにより、任意の永続化ストレージ、あるいは動的データに対してSQLライクなクエリ・インターフェースを提供することができます。

# モチベーション

仕事でとあるローコード・プラットフォームのカスタマイズを行ったときに、複数のテーブルに跨がるデータを親子のデータ構造に纏めるのが苦痛でした。

そのプラットフォームではテーブル同士のリレーションシップが設定できるのですが、カスタマイズにおいては、**テーブル毎に異なるREST APIエンドポイント**からデータを取得する必要がありました。

欲しいデータ構造にするために自力で (つまり、毎回コードを書いて) 結合したり集計したりするのは効率が悪く、「ちょっとしたカスタマイズ」が複雑化しやすくなります。

同じローコード・プラットフォームに分類される**こともある**Salesforceならば**SOQL**一発で取得できるのに、と考えたくなります。
(コード書かなくてもいいし、書くとなったら気持ちよく書けるプラットフォームがいいですね！)

少し前に[自作のパーサーコンビネータ・ライブラリ](https://github.com/shellyln/fruitsconfits)で割と複雑な構文解析を行う[ライブラリ](https://github.com/shellyln/tynder)を作成できたので、SQLっぽい言語くらい余裕だろうと思って作成を開始しましたが、それ以外のところで色々上手くいかず、リリースまでに予定の倍である2か月も掛かってしまいました😓

# 使い方の簡単な説明

## インストール

```bash
npm install open-soql
```

## 利用

- まず、リゾルバを設定します。

```tsx
import { build } from 'open-soql/modules/builder';
import { staticJsonResolverBuilder,
         staticCsvResolverBuilder,
         passThroughResolverBuilder } from 'open-soql/modules/resolvers';

const commands = build({
    relationships: {                                    // リレーションシップを定義します
        Account: {                                      // リゾルバ名
            Contacts: ['Contact'],                      // リレーションシップ項目名 → リゾルバ名
            Opportunities: ['Opportunity', 'Account'],  // 相手のリレーションシップ項目を明示的に指定する場合
        },
        Contact: {
            Account: 'Account',
        },
        Opportunity: {
            Account: 'Account',
        },
        Event: {
            Account: { resolver: 'Account', id: 'WhatId' },  // Id項目を明示的に指定する場合
            Contact: { resolver: 'Contact', id: 'WhatId' },
            Opportunity: { resolver: 'Opportunity', id: 'WhatId' },
        },
    },
    resolvers: {  // リゾルバを定義します
        query: {
            Contact: staticCsvResolverBuilder(  // 固定のJSON・CSV・オブジェクト配列からは、簡単にリゾルバを作成できるように標準のリゾルバ実装を提供しています。
                'Contact', () => Promise.resolve(`
                    Id         , Foo      , Bar      , Baz      , Qux      , Quux  ,   Corge , Grault       , Garply                 , AccountId
                    Contact/z1 , aaa/z1   , bbb/z1   , ccc/z1   , ddd/z1   , false ,    -1.0 , 2019-12-31   , 2019-12-31T23:59:59Z   , Account/z1
                    Contact/z2 , aaa/z2   , bbb/z2   , ccc/z2   , ddd/z2   , true  ,     0.0 , 2020-01-01   , 2020-01-01T00:00:00Z   , Account/z1
                    Contact/z3 , "aaa/z3" , "bbb/z3" , "ccc/z3" , "ddd/z3" ,       ,     1   , "2020-01-02" , "2020-01-01T00:00:01Z" , "Account/z2"
                    Contact/z4 ,          ,          ,          ,          ,       ,         ,              ,                        ,
                    Contact/z5 ,       "" ,       "" ,      " " ,       "" ,       ,         ,              ,                        ,
                `)
            ),
            Account: staticCsvResolverBuilder(
                'Account', () => Promise.resolve(`
                    Id         , Name     , Address
                    Account/z1 , fff/z1   , ggg/z1
                    Account/z2 , fff/z2   , ggg/z2
                    Account/z3 , "fff/z3" , "ggg/z3"
                    Account/z4 ,          ,
                    Account/z5 ,       "" ,       ""
                `)
            ),
            Opportunity: staticCsvResolverBuilder(
                'Opportunity', () => Promise.resolve(`
                    Id             , Name     , Amount , AccountId
                    Opportunity/z1 , hhh/z1   ,   1000 , Account/z1
                    Opportunity/z2 , hhh/z2   ,   2000 , Account/z1
                    Opportunity/z3 , "hhh/z3" ,   3000 , Account/z2
                    Opportunity/z4 ,          ,        ,
                    Opportunity/z5 , ""       ,      0 , Account/z2
                `)
            ),
            Event: staticCsvResolverBuilder(
                'Event', () => Promise.resolve(`
                    Id         , Title    , Address  , WhatId
                    Event/z1   , iii/z1   , jjj/z1   , Account/z2
                    Event/z2   , iii/z2   , jjj/z2   , Contact/z2
                    Event/z3   , "iii/z3" , "jjj/z3" , Contact/z3
                    Event/z4   ,          ,          ,
                    Event/z5   ,       "" ,       "" , Opportunity/z5
                `)
            ),
        },
    },
});

const { soql, insert, update, remove, transaction } = commands;
```

- リゾルバ構築で得られたコマンドを使って、クエリ・DMLを発行します。

```tsx
// Contact > Account > Opportunity の3階層から成るオブジェクトを返します
const selected = await soql<Partial<Contact>>`
    select
        id, foo, bar, baz, acc.id, acc.name,
        (select id, name, amount from acc.opportunities)
    from
        contact con, account acc
    order by id, foo desc
    offset 1 limit 2`;

const inserted = await insert('Contact', [{
    Foo: 'foo', ...
}]);
const updated = await update('Contact', inserted);
await remove('Contact', updated);
```

- トランザクション・スコープでクエリ・DMLを纏めることもできます。

```tsx
await transaction(async (commands, tr) => {
    const { soql, insert, update, remove } = commands;

    const inserted = await insert('Contact', [{
        Foo: 'foo',
    }]);
    const selected = await soql<Partial<Contact>>`Select Id, Foo from Contact`;
    const updated = await update('Contact', selected);
    await remove('Contact', updated);
});
```

- 集計も可能です。

```tsx
const aggregationResult = await soql<ContactAgg>`
    Select
        AccountId
      , count()
      , count(id) cnt
      , sum(bar) sum
    from
        Contact
    where
        foo > ''
    group by AccountId
    having count(id) > 0
    order by AccountId
    offset 1 limit 2`;
```

# N+1問題 (N+1クエリ問題) への対策

このようなリゾルバで解決するようなクエリ・エンジンやO/RM等の実装では、いわゆる「**N+1問題**」が発生します。

親オブジェクトとその関連する子オブジェクトを取得する際、親オブジェクトのリストをクエリするのに1回、取得できたN個の親に対する子を得るためにN回のクエリが必要になり、パフォーマンス問題が発生します。
合計のクエリ回数が N+1 回であるため、**N+1問題**と呼ばれます。

GraphQLでは[DataLoader](https://github.com/graphql/dataloader)という仕組みを使って解決することができます。

Open SOQLに於いては、クエリ前のイベントでデータを事前に一括取得することで解決します (親レコード一覧が得られるので、そこからキーを取得してクエリします)。

```tsx
const commands = build({
    ...
    events: { // optional: For resolving transaction and N+1 query problem.
        beginTransaction: (evt) => Promise.resolve(),
        endTransaction: (evt, err) => Promise.resolve(),
        beginExecute: (evt) => Promise.resolve(),
        endExecute: (evt, err) => Promise.resolve(),
        beforeMasterSubQueries: (evt) => Promise.resolve(),  // 一括取得します
        afterMasterSubQueries: (evt) => Promise.resolve(),
        beforeDetailSubQueries: (evt) => Promise.resolve(),  // 一括取得します
        afterDetailSubQueries: (evt) => Promise.resolve(),
    },
    ...
});
```

# 追記 (2020-08-17)
使い方のサンプルリポジトリを作成しました。
(トランスパイラ・バンドラを使用せずに直接ES ModulesをNodeで実行しています)
https://github.com/shellyln/open-soql-usage-example


# 今後の開発について

~~まだ、ネストした関数呼び出しが処理できないため、当面は関数呼び出し関連の改善を優先して行う予定です (集計関数とスカラー関数両方を含むネストの対応が特に課題です)。~~
余裕ができたら「とあるローコード・プラットフォーム」用のリゾルバも作成したいです・・・

# 追記 (2020-08-26)
`v0.2`を2020-08-25にリリースしました。
https://twitter.com/shellyl_n/status/1298031673037086721
スカラー／集計関数ともにネストした関数呼び出しに対応しています。

# さいごに

よろしければ、是非、使ってみてください。
フィードバックを頂けると大変嬉しいです🥰