JSX でRDBのスキーマビルダーを作る
動機
JSX記法はReactだけではく他のアプリケーションでも使用できます。そこで、現在個人開発で作っているSQLiteのテーブル作成クエリをもっと書きやすくしたいと思い、JSX記法で書けるようにしてみました。
方針
- 古いJSXトランスフォーマーを使う
- JSXの要素は文字列を返す
- TypeScriptで使えるようにする(TSX)
- JSXのファクトリ関数の名前は
h
とする - JSXのFragmentのファクトリ関数の名前は
Factory
とする - SQLiteのSQL文を作成できる
やり方
Viteの設定
vite.config.js
にJSXのファクトリ関数の名前を渡しました。これによりファイル中にJSXがあった場合に、Viteが依存しているesbuildが h
関数と Fragment
を使うようになりました。
import { defineConfig } from 'vite'
export default defineConfig({
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
},
})
tsconfig.jsonにJSXのための設定を追加
次に、ファクトリ関数 h
および Fragment
を TypeScriptコンパイラに認識させるために、 tsconfig.json
に設定を追加しました。JSXから普通のJSへの変換はesbuildが行うので、 "jsx": "preserve"
としました。
{
"compilerOptions": {
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}
}
型定義の追加
TypeScriptの設定はtsconfig.json
を書き換えるだけでは不十分で、.d.ts
ファイルに下記の型定義を追加しました。
declare namespace JSX {
interface ElementChildrenAttribute {
children: {}
}
type Element = string
}
ここでおこなったことは、下記の2点です。
- JSXの子要素は
children
prop とすること(型チェックが通らない) - JSXの要素は文字列型であること(明示的に指定しないと
any
型として扱われる)
ファクトリ関数の実装
ViteとTypeScriptの設定が完了したので、実装を進めていきました。
まず、JSXのファクトリ関数を実装しました。
export function h<T>(statement: (props: T) => string, props: T, ...children: any[]) {
return statement({ ...props, children })
}
type Props = {
children: JSX.Element | JSX.Element[]
}
export const Fragment = (props: Props) => {
return (props.children as JSX.Element[]).join('\n')
}
古いJSX トランスフォーマーを使っているため、 children
は可変長引数で受け取ります。
ここまでで実際に h
関数を使うとこのようになります。
import { Fragment, h } from './jsxFactory'
const City = (props: { cityName: string }) => {
return props.cityName
}
const Prefecture = (props: { prefectureName: string; children: JSX.Element | JSX.Element[] }) => {
const children = (props.children as JSX.Element[]).join('&')
return `${props.prefectureName} has ${children}`
}
const Result = () => {
return (
<>
<Prefecture prefectureName="東京">
<City cityName="町田" />
<City cityName="八王子" />
</Prefecture>
<Prefecture prefectureName="大阪">
<City cityName="堺" />
<City cityName="東大阪" />
</Prefecture>
</>
)
}
というコードが、下記に変換されます。
import { Fragment, h } from './jsxFactory'
// 省略
const Result = () => {
return h(
Fragment,
null,
h(
Prefecture,
{ prefectureName: '東京' },
h(City, { cityName: '町田' }),
h(City, { cityName: '八王子' }),
),
h(
Prefecture,
{ prefectureName: '大阪' },
h(City, { cityName: '堺' }),
h(City, { cityName: '東大阪' }),
),
)
}
Result
関数を実行すると、 東京 has 町田&八王子\n大阪 has 堺&東大阪
という文字列が返ってきます。
スキーマビルダーの実装
ここからは、実際にSQL文を作るための関数を実装しました。
まずは CREATE TABLE
文を返す関数です。
type Props = {
children: string | string[]
name: string
}
export const Table = ({ children, name }: Props) => {
return `CREATE TABLE IF NOT EXISTS ${name} (${(children as string[]).join(',')});`
}
children
は渡される要素数が1個の場合に string
型になってしまいます。そこで、 Props['children']
をユニオン型にする必要があります。ただ古いJSXトランスフォーマーの場合は必ず配列で受け取るので、 as string[]
で明示的なキャストをして問題ないです。
次に、テーブルのカラムの設定を返す関数です。
type References = {
table: string
column: string
onUpdate?: 'CASCADE' | 'RESTRICT'
onDelete?: 'CASCADE' | 'RESTRICT'
}
type Constraints = {
check?: (columnName: string) => string
notNull?: boolean
primaryKey?: boolean
references?: References
}
function constraintsToSql(
columnName: string,
{ check, notNull = false, primaryKey = false, references }: Constraints = {}
) {
return [
check ? `CHECK(${check(columnName)})` : '',
notNull ? `NOT NULL` : '',
primaryKey ? `PRIMARY KEY` : '',
references ? referencesToSql(references) : '',
].filter((text) => !!text)
}
function referencesToSql({ table, column, onUpdate, onDelete }: References) {
return [
`REFERENCES ${table}(${column})`,
onUpdate ? `ON UPDATE ${onUpdate}` : '',
onDelete ? `ON DELETE ${onDelete}` : '',
]
.filter((text) => !!text)
.join(' ')
}
export const column = (name: string, type: string, constraints: Constraints) => {
return [name, type, ...constraintsToSql(name, constraints)].join(' ')
}
type Props = {
name: string
} & Constraints
export const TextColumn = ({ name, ...constraints }: Props) => {
return column(name, 'TEXT', constraints)
}
export const IntegerColumn = ({ name, ...constraints }: Props) => {
return column(name, 'INTEGER', constraints)
}
これで、実装は終わりです(ただしここでは、パラメータをバッククオートで囲う処理を省略しています)。
使い方
JSX 記法でテーブル作成クエリを書く
最後に、ここまで作った実装を使って、テーブル作成クエリを書きました。
import { Fragment, h } from './jsxFactory'
import { Table } from './Table'
import { IntegerColumn, TextColumn } from './Column'
export const Schema = () => {
return (
<>
<Table name="posts">
<IntegerColumn name="id" primaryKey notNull />
<TextColumn name="title" notNull />
<TextColumn name="body" notNull />
</Table>
<Table name="post_comments">
<IntegerColumn name="id" primaryKey notNull />
<IntegerColumn
name="post_id"
notNull
references={{
table: "posts",
column: "id",
onUpdate: "CASCADE",
onDelete: "CASCADE",
}}
/>
<TextColumn name="body" notNull />
</Table>
</>
)
}
感想
読みやすくなったかどうかは、正直なんとも言えませんが、JSX/TSXの理解を深めることができたのでよかったです。宣言的なコードを書くときにはJSX を使うと綺麗に書ける可能性があるので、ぜひ参考にしてみてください。
Discussion