27
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-17

本日(2020-08-17)、Open SOQL 最初の安定版となる v0.1.0 をリリースしました🎉

Open SOQLはSalesforceで使われている独自のクエリ言語「SOQL」(Salesforce Object Query Language) のオープンソース実装版です。

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

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

Open SOQLはオリジナルのSOQLとは異なり、SalesforceのデータをクエリするものではありませんGraphQLApollo Server等と同様に、ノードに対応するリゾルバ (コールバック関数) にデータの取得・更新を移譲します。これにより、任意の永続化ストレージ、あるいは動的データに対してSQLライクなクエリ・インターフェースを提供することができます。

モチベーション

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

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

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

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

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

使い方の簡単な説明

インストール

npm install open-soql

利用

  • まず、リゾルバを設定します。
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を発行します。
// 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を纏めることもできます。
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);
});
  • 集計も可能です。
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という仕組みを使って解決することができます。

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

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
スカラー/集計関数ともにネストした関数呼び出しに対応しています。

さいごに

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

27
16
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?