はじめに
先日、「SQLライクな「グラフ」クエリエンジンOpen SOQLを作ってみた」という記事で、クエリ言語SOQLのオープンソース実装「Open SOQL」をご紹介しました。
今回は、Reactのアプリケーション状態ストア(つまり、Redux等の代替)としてOpen SOQLを使ってみたいと思います。
(注:現時点では更新の購読機能(Pub / Sub)がOpen SOQLにはありませんので、まだ実用レベルではありません)
【2020-08-28追記】v0.3.0
にて Publish/Subscribeメッセージ送信に対応しました
インストール
npx create-react-app my-open-soql-app --template typescript
cd my-open-soql-app
npm install open-soql
コードの変更
カスタムフックを定義する
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を参考にしました。
リゾルバを定義する
リゾルバによってストアを構築します。
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;
}
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 のリゾルバを定義できます
},
});
フックを使うコンポーネントを作る
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;
コンポーネントをページに追加する
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
さいごに
Open SOQLのクライアント・ローカルなリゾルバをアプリケーション状態ストアとしてReactを使うことができました。
前述の通り、現時点ではOpen SOQLに Pub/Sub によるイベント機能が無いため、離れた階層にあるコンポーネントからのストア更新で自動的に描画更新させることはできませんが、Open SOQLの次期バージョンではDML実行によるサブスクライバへの通知を実現していきたいと考えています。
【2020-08-28追記】v0.3.0
にて Publish/Subscribeメッセージ送信に対応しました。