3
1

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.

【脱Redux?】Open SOQLを状態ストアとしてReactでアプリを作ってみる

Last updated at Posted at 2020-08-27

はじめに

先日、「SQLライクな「グラフ」クエリエンジンOpen SOQLを作ってみた」という記事で、クエリ言語SOQLのオープンソース実装「Open SOQL」をご紹介しました。

open-soql

今回は、Reactのアプリケーション状態ストア(つまり、Redux等の代替)としてOpen SOQLを使ってみたいと思います。
(注:現時点では更新の購読機能(Pub / Sub)がOpen SOQLにはありませんので、まだ実用レベルではありません)
【2020-08-28追記】v0.3.0 にて Publish/Subscribeメッセージ送信に対応しました:tada:

インストール

npx create-react-app my-open-soql-app --template typescript
cd my-open-soql-app
npm install open-soql

コードの変更

カスタムフックを定義する

soql-hooks.ts
import { useState } from 'react';
import { IQuery, QueryParams } from 'open-soql/modules/types';

export function useSoql<R>(query: IQuery, params?: QueryParams) {
    const [loading, setLoading] = useState(null as (boolean | null));
    const [err, setErr] = useState(null as (any | null));
    const [data, setData] = useState(null as (R[] | null));
    
    const refetch = () => {
        query.execute<R>(params)
            .then(d => {
                setLoading(false);
                setErr(null);
                setData(d);
            })
            .catch(e => {
                setLoading(false);
                setErr(e);
                setData(null);
            });
        setLoading(true);
        setErr(null);
        setData(null);
    };

    if (loading === null) {
        refetch();
    }

    return { loading, err, data, refetch };
}

このカスタムフックのインターフェースはApolloClientのReact APIを参考にしました。

リゾルバを定義する

リゾルバによってストアを構築します。

types.ts
export interface Account_norels {
    Id: string;
    Name: string | null;
    Address: string | null;
}

export interface Account extends Account_norels {
    Contacts: Contact[] | null;
}

export interface Contact_norels {
    Id: string;
    Foo: string | null;
    AccountId: string | null;
}

export interface Contact extends Contact_norels {
    Account: Account;
}
commands.ts
import { build } from 'open-soql/modules/builder';
import { Account_norels, Contact_norels } from './types';

type Store = {
    Account: Account_norels[];
    Contact: Contact_norels[];
};

const store: Store = {
    Account: [
        { Id: 'Account/z1', Name: 'bbb/z1', Address: 'ccc/z1' },
        { Id: 'Account/z2', Name: 'bbb/z2', Address: 'ccc/z2' },
        { Id: 'Account/z3', Name: 'bbb/z3', Address: 'ccc/z3' },
        { Id: 'Account/z4', Name: null    , Address: null     },
        { Id: 'Account/z5', Name: ''      , Address: ''       },
    ],
    Contact: [
        { Id: 'Contact/z1', Foo: 'aaa/z1', AccountId: 'Account/z1' },
        { Id: 'Contact/z2', Foo: 'aaa/z2', AccountId: 'Account/z1' },
        { Id: 'Contact/z3', Foo: 'aaa/z3', AccountId: 'Account/z2' },
        { Id: 'Contact/z4', Foo: null    , AccountId: null         },
        { Id: 'Contact/z5', Foo: ''      , AccountId: null         },
    ],
};

export const { compile, soql, insert, update, remove, touch, transaction, subscribe, unsubscribe } = build({
    relationships: { // オブジェクト間のリレーションシップを定義します
        Account: { Contacts: ['Contact'] },
        Contact: { Account: 'Account' },
    },
    resolvers: {
        query: { // 選択クエリのリゾルバ
            Account: (fields, conditions, limit, offset, ctx) => {
                let data = store.Account;
                if (ctx.parent && ctx.parentType === 'detail') {
                    data = data.filter(x => x.Id === (ctx.parent as any)[ctx.foreignIdField!]);
                }
                return Promise.resolve(data.map(x => ({...x})));
            },
            Contact: (fields, conditions, limit, offset, ctx) => {
                let data = store.Contact;
                if (ctx.parent && ctx.parentType === 'master') {
                    data = data.filter(x => x.AccountId === (ctx.parent as any)[ctx.masterIdField!]);
                }
                return Promise.resolve(data.map(x => ({...x})));
            },
        },
        update: { // 更新DMLのリゾルバ
            Account: (records: Partial<Account_norels>[], ctx) => {
                const ret: Partial<Account_norels>[] = [];
                for (const rec of records) {
                    const index = store.Account.findIndex(x => x.Id === rec.Id);
                    if (index < 0) {
                        throw new Error('Record is not exists!');
                    }

                    const original = store.Account[index];
                    const changed = { ...original, ...rec };

                    store.Account[index] = changed;
                    ret.push(changed)
                }
                return Promise.resolve(ret);
            },
            Contact: (records: Partial<Contact_norels>[], ctx) => {
                const ret: Partial<Contact_norels>[] = [];
                for (const rec of records) {
                    const index = store.Contact.findIndex(x => x.Id === rec.Id);
                    if (index < 0) {
                        throw new Error('Record is not exists!');
                    }

                    const original = store.Contact[index];
                    const changed = { ...original, ...rec };

                    store.Contact[index] = changed;
                    ret.push(changed)
                }
                return Promise.resolve(ret);
            },
        },
        // 他に insert, remove のリゾルバを定義できます
    },
});

フックを使うコンポーネントを作る

Accounts.tsx
import   React,
       { useEffect }   from 'react';
import { Subscriber }  from 'open-soql/modules/types';
import { Account,
         Contact }     from './types';
import { compile,
         update,
         subscribe,
         unsubscribe } from './commands';
import { useSoql }     from './soql-hooks';
import                      './Accounts.css';

const query = compile`
    Select
        Id
      , Name
      , Address
      , (Select Id, Foo from Contacts where Foo > '')
    from
        Account
    where
        Name > :condName`;

function Accounts() {
    const { loading, err, data, refetch } = useSoql<Partial<Account>>(query, { condName: '' });

    const subscriber: Subscriber = ({resolver, on, id}) => {
        refetch();
    };

    // 副作用フックでDMLイベントを購読します
    // 登録解除時は、登録時と同じ関数オブジェクトを渡す必要があります
    useEffect(() => {
        const d = data;
        if (d) {
            for (const acc of d) {
                subscribe('Account', acc.Id, subscriber);
                for (const con of acc.Contacts ?? []) {
                    subscribe('Contact', con.Id, subscriber);
                }
            }
        }

        return () => {
            if (d) {
                for (const acc of d) {
                    unsubscribe('Account', acc.Id, subscriber);
                    for (const con of acc.Contacts ?? []) {
                        unsubscribe('Contact', con.Id, subscriber);
                    }
                }
            }
        }
    });

    if (loading || !data) {
        return <div>Loading...</div>;
    }
    if (err) {
        return <div>Error!</div>;
    }

    const handleAccountClick = async (rec: Partial<Account>) => {
        await update('Account', {...rec, Name: `${rec.Name}+`});
    };

    const handleContactClick = async (rec: Partial<Contact>) => {
        await update('Contact', {...rec, Foo: `${rec.Foo}+`});
    };

    return (
        <div className="Accounts-wrap">
            <ul>{data.map(x =>
                <li key={x.Id} style={{cursor: 'pointer'}}>
                    <h3 onClick={() => handleAccountClick(x)}>{x.Name} - {x.Address}</h3>
                    <ul>{(x.Contacts?? []).map(c =>
                        <li key={c.Id}>
                            <div onClick={() => handleContactClick(c)}>{c.Foo}</div>
                        </li>
                    )}
                    </ul>
                </li>
            )}
            </ul>
        </div>
    );
}

export default Accounts;

コンポーネントをページに追加する

App.tsx
import React from 'react';
import logo from './logo.svg';
import './App.css';
import Accounts from './Accounts'; // ← 追記

function App() {
  return (
    <div className="App">
      <header className="App-header">
        ...                                 { /* 既存コード */ }
        <div style={{ display: "flex" }}>   { /* ← 追記 */ }
          <div style={{ border: "solid 1px", margin: 2, paddingRight: 20 }}>
            <Accounts />
          </div>
          <div style={{ border: "solid 1px", margin: 2, paddingRight: 20 }}>
            <Accounts />
          </div>
        </div>
      </header>
    </div>
  );
}

export default App;

コード全体は https://github.com/shellyln/open-soql-react-hooks-example-app にて見られます。

実行

npm start
execution-result イベント購読により、左右のコンポーネントの表示が同期されます。

さいごに

Open SOQLのクライアント・ローカルなリゾルバをアプリケーション状態ストアとしてReactを使うことができました。

前述の通り、現時点ではOpen SOQLに Pub/Sub によるイベント機能が無いため、離れた階層にあるコンポーネントからのストア更新で自動的に描画更新させることはできませんが、Open SOQLの次期バージョンではDML実行によるサブスクライバへの通知を実現していきたいと考えています。
【2020-08-28追記】v0.3.0 にて Publish/Subscribeメッセージ送信に対応しました。

3
1
0

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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?