📝

JSX でRDBのスキーマビルダーを作る

2022/03/06に公開

動機

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 を使うようになりました。

vite.config.js
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" としました。

tsconfig.json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  }
}

型定義の追加

TypeScriptの設定はtsconfig.jsonを書き換えるだけでは不十分で、.d.ts ファイルに下記の型定義を追加しました。

jsx.d.ts
declare namespace JSX {
  interface ElementChildrenAttribute {
    children: {}
  }

  type Element = string
}

ここでおこなったことは、下記の2点です。

  • JSXの子要素は children prop とすること(型チェックが通らない)
  • JSXの要素は文字列型であること(明示的に指定しないと any 型として扱われる)

ファクトリ関数の実装

ViteとTypeScriptの設定が完了したので、実装を進めていきました。
まず、JSXのファクトリ関数を実装しました。

jsxFactory.ts
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 関数を使うとこのようになります。

example.tsx
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>
    </>
  )
}

というコードが、下記に変換されます。

example.js
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 文を返す関数です。

Table.ts
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[] で明示的なキャストをして問題ないです。

次に、テーブルのカラムの設定を返す関数です。

Column.ts
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 記法でテーブル作成クエリを書く

最後に、ここまで作った実装を使って、テーブル作成クエリを書きました。

schema.tsx
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