[TS] Firestoreを型安全にするライブラリ Fireschema を作った [rules自動生成/データ自動型付け]

-   **8/18 更新**
    -   Custom Transformer を使用し、**`documentSchema<T>()` の型引数 (`T` の部分) の AST 解析によって rules の文字列が自動生成**できるようになったため、`documentSchema()` の引数が不要になりました

スキーマレスな Firestore では、その気になれば一般ユーザーが予期しない構造のデータをセットできてしまうので、**セキュリティルールで書き込み時のスキーマ検証を設定**する必要があります。しかし、セキュリティルールはモデル定義に合わせて手動で書かなければならないのでミスが起きやすく、そのためにテストを書くのもコストに見合わない感があります。

**例: `User` 型のモデルとそのスキーマ検証を行う関数**

```ts
type User = {
    name: string
    displayName: string | null
    age: number
    createdAt: firestore.FieldValue.Timestamp
}
```

```
function validateUser(data) {
  return data.name is string
    && (data.displayName is string || data.displayName is null)
    && data.age is int
    && data.createdAt is timestamp;
}
```

また、TypeScript を使った開発環境においては、ドキュメントデータを自動で型付けすることができないので、**誤ったキャストやコレクションパスの typo** などの可能性があり、これらもコンパイル時に検出するのが理想的です。

**Fireschema** は、コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、**firestore.rules の生成**や**ドキュメントの型付け**などを自動で行い、これらの問題を解決します。

[![ReadMe Card](https://github-readme-stats.vercel.app/api/pin/?username=yarnaimo&repo=fireschema&show_owner=true)](https://github.com/yarnaimo/fireschema)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">TypeScriptを使った開発でFirestoreを型安全にするライブラリ Fireschema を作りました！！🎉<br><br>コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成やドキュメントの型付けなどを自動で行います！<a href="https://t.co/rFgnJUPSV9">https://t.co/rFgnJUPSV9</a></p>&mdash; やまいも (@yarnaimo) <a href="https://twitter.com/yarnaimo/status/1293427352379117572?ref_src=twsrc%5Etfw">August 12, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## インストール

Fireschema は **Variadic Tuple Types** を使用しているため **TypeScript 4.0 以上** のみサポートしています。(TypeScript 4.0 は現在 β 版です)

```sh
yarn add fireschema
yarn add -D typescript@^4.0.0-beta ts-node
```

## Setup

### Custom Compiler / Transformer

Fireschema では TypeScript の AST から型情報を取得する目的で **Custom Transformer** を使用するため、ビルド時は **ttypescript** という Custom Compiler を使う必要があります。

Custom Compiler / Transformer を使用するには、設定ファイルに以下の内容を追加してください。

**package.json**

`ttsc` / `ts-node` は環境変数 `TS_NODE_PROJECT` を使うと任意の `tsconfig.json` が指定できます。

```json
{
    "scripts": {
        "build": "ttsc", // <- tsc
        "ts-node": "ts-node --compiler ttypescript" // <- ts-node
    }
}
```

**tsconfig.json**

```json
{
    "compilerOptions": {
        "plugins": [
            {
                "transform": "fireschema/transformer"
            }
        ]
    }
}
```

**jest.config.js**

```js
module.exports = {
    globals: {
        'ts-jest': {
            tsConfig: 'tsconfig.json',
            compiler: 'ttypescript',
        },
    },
}
```

### Override Dependencies

fireschema が依存する一部のパッケージは **TypeScript 3.9** に依存しているため、Selective dependency resolutions で**依存関係を上書き**する必要があります。(yarn のみ対応)

```json
{
    "resolutions": {
        "fireschema/**/typescript": "^4.0.0-beta"
    }
}
```

## 使い方

**注意事項**

-   fireschema は変数名に応じてコードを変換するため、**fireschema からのインポート以外で以下の変数名を使用しないでください**。
    -   **`$documentSchema`**
    -   **`$collectionAdapter`**
    -   **`__$__`**

**想定ケース**

-   /users/{uid}
    -   ユーザー (`User`)
-   /users/{uid}/posts/{postId}
    -   ユーザーの投稿 (`PostA` または `PostB`)

### 1. スキーマ定義

Fireschema では、まず各モデルの **documentSchema** と **collectionAdapter** を定義してから、それを使って DB 全体のコレクション構造を定義します。

#### `$documentSchema<T>()`

firestore.rules に出力されるスキーマを指定します。この関数の呼び出しコードは、Custom Transformer によって型引数 (`T` の部分) が AST 解析された- **8/18 更新** - 
TypeScript 黒魔術に手を染めました
 Custom Transformer を使用し、**`documentSchema<T>()` の型引数 (`T` の部分) の AST 解析によって rules の文字列が自動生成**できるようになったため、`documentSchema()` の引数が不要になりました

スキーマレスな Firestore では、その気になれば一般ユーザーが予期しない構造のデータをセットできてしまうので、**セキュリティルールで書き込み時のスキーマ検証を設定**する必要があります。しかし、セキュリティルールはモデル定義に合わせて手動で書かなければならないのでミスが起きやすく、そのためにテストを書くのもコストに見合わない感があります。

**例: `User` 型のモデルとそのスキーマ検証を行う関数**

```ts
type User = {
    name: string
    displayName: string | null
    age: number
    createdAt: firestore.FieldValue.Timestamp
}
```

```
function validateUser(data) {
  return data.name is string
    && (data.displayName is string || data.displayName is null)
    && data.age is int
    && data.createdAt is timestamp;
}
```

また、TypeScript を使った開発環境においては、ドキュメントデータを自動で型付けすることができないので、**誤ったキャストやコレクションパスの typo** などの可能性があり、これらもコンパイル時に検出するのが理想的です。

**Fireschema** は、コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、**firestore.rules の生成**や**ドキュメントの型付け**などを自動で行い、これらの問題を解決します。

[![ReadMe Card](https://github-readme-stats.vercel.app/api/pin/?username=yarnaimo&repo=fireschema&show_owner=true)](https://github.com/yarnaimo/fireschema)

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">TypeScriptを使った開発でFirestoreを型安全にするライブラリ Fireschema を作りました！！🎉<br><br>コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成やドキュメントの型付けなどを自動で行います！<a href="https://t.co/rFgnJUPSV9">https://t.co/rFgnJUPSV9</a></p>&mdash; やまいも (@yarnaimo) <a href="https://twitter.com/yarnaimo/status/1293427352379117572?ref_src=twsrc%5Etfw">August 12, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## インストール

Fireschema は **Variadic Tuple Types** を使用しているため **TypeScript 4.0 以上** のみサポートしています。(TypeScript 4.0 は現在 β 版です)

```sh
yarn add fireschema
yarn add -D typescript@^4.0.0-beta ts-node
```

## Setup

### Custom Compiler / Transformer

Fireschema では TypeScript の AST から型情報を取得する目的で **Custom Transformer** を使用するため、ビルド時は **ttypescript** という Custom Compiler を使う必要があります。

Custom Compiler / Transformer を使用するには、設定ファイルに以下の内容を追加してください。

**package.json**

`ttsc` / `ts-node` は環境変数 `TS_NODE_PROJECT` を使うと任意の `tsconfig.json` が指定できます。

```json
{
    "scripts": {
        "build": "ttsc", // <- tsc
        "ts-node": "ts-node --compiler ttypescript" // <- ts-node
    }
}
```

**tsconfig.json**

```json
{
    "compilerOptions": {
        "plugins": [
            {
                "transform": "fireschema/transformer"
            }
        ]
    }
}
```

**jest.config.js**

```js
module.exports = {
    globals: {
        'ts-jest': {
            tsConfig: 'tsconfig.json',
            compiler: 'ttypescript',
        },
    },
}
```

### Override Dependencies

fireschema が依存する一部のパッケージは **TypeScript 3.9** に依存しているため、Selective dependency resolutions で**依存関係を上書き**する必要があります。(yarn のみ対応)

```json
{
    "resolutions": {
        "fireschema/**/typescript": "^4.0.0-beta"
    }
}
```

## 使い方

**注意事項**

-   fireschema は変数名に応じてコードを変換するため、**fireschema からのインポート以外で以下の変数名を使用しないでください**。
    -   **`$documentSchema`**
    -   **`$collectionAdapter`**
    -   **`__$__`**

**想定ケース**

-   /users/{uid}
    -   ユーザー (`User`)
-   /users/{uid}/posts/{postId}
    -   ユーザーの投稿 (`PostA` または `PostB`)

### 1. スキーマ定義

Fireschema では、まず各モデルの **documentSchema** と **collectionAdapter** を定義してから、それを使って DB 全体のコレクション構造を定義します。

#### `$documentSchema<T>()`

firestore.rules に出力されるスキーマを指定します。この関数の呼び出しコードは、Custom Transformer によって型引数 (`T` の部分) が AST 解析された上で、**rules の文字列に変換**されます。

#### `$collectionAdapter<T>()({ selectors: (q) => <query functions> })`

データ取得時に使用するクエリを定義します。(使い方は `PostAdapter` の例を参照)

#### 例

```ts
import {
    $adapter,
    $allow,
    $collectionGroups,
    $docLabel,
    $documentSchema,
    $functions,
    $or,
    $schema,
    $collectionAdapter,
    createFirestoreSchema,
    FTypes,
} from '..'

// user
type User = {
    name: string
    displayName: string | null
    age: number
    timestamp: FTypes.Timestamp
    options: { a: boolean }
}
const UserSchema = $documentSchema<User>()
const UserAdapter = $collectionAdapter<User>()({})

// post
type PostA = {
    type: 'a'
    tags: { id: number; name: string }[]
    text: string
}
type PostB = {
    type: 'b'
    tags: { id: number; name: string }[]
    texts: string[]
}
const PostSchema = $documentSchema<PostA | PostB>()
const PostAdapter = $collectionAdapter<PostA | PostB>()({
    selectors: (q) => ({
        byTag: (tag: string) => q.where('tags', 'array-contains', tag),
    }),
})
```

#### `createFirestoreSchema`

DB 全体のコレクション構造を定義します。**ネストされたコレクション構造をそのままオブジェクトで書ける**ので、視覚的にも分かりやすくなっています。

`$docLabel`・`$schema` など、コレクションのメタデータを指定する key は string ではなく **Symbol** なので、内部で処理するときに同じ階層に混在する child collection と区別しています。

スキーマ定義は **`firestoreSchema`** として named export してください。

```ts
export const firestoreSchema = createFirestoreSchema({
    [$functions]: {
        // /admins/<uid> が存在するかどうか
        ['isAdmin()']: `
            return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
        `,

        // アクセスしようとするユーザーの uid が {uid} と一致するかどうか
        ['matchesUser(uid)']: `
            return request.auth.uid == uid;
        `,
    },

    [$collectionGroups]: {
        users: {
            [$docLabel]: 'uid',
            [$schema]: UserSchema,
            [$adapter]: UserAdapter,
            [$allow]: {
                read: true,
            },
        },
    },

    // /users/{uid}
    users: {
        [$docLabel]: 'uid', // {uid} の部分
        [$schema]: UserSchema, // documentSchema
        [$adapter]: UserAdapter, // collectionAdapter
        [$allow]: {
            // アクセス制御
            read: true, // 誰でも可
            write: $or(['matchesUser(uid)', 'isAdmin()']), // {uid} と一致するユーザー or 管理者のみ可
        },

        // /users/{uid}/posts/{postId}
        posts: {
            [$docLabel]: 'postId',
            [$schema]: PostSchema,
            [$adapter]: PostAdapter,
            [$allow]: {
                read: true,
                write: $or(['matchesUser(uid)']), // {uid} と一致するユーザーのみ可
            },
        },
    },
})
```

### 2. firestore.rules の生成

**`yarn fireschema <スキーマのパス>.ts`** を実行すると firestore.rules が生成されます。`ttsc` / `ts-node` と同じく、環境変数 `TS_NODE_PROJECT` で任意の `tsconfig.json` が指定できます。

#### 生成される firestore.rules の例

```rules
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    function isAdmin() {
      return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
    }

    function matchesUser(uid) {
      return request.auth.uid == uid;
    }

    match /{path=**}/users/{uid} {
      allow read: if true;
    }

    match /users/{uid} {
      function __validator_0__(data) {
        return (
          data.name is string
            && ((data.displayName == null || !(displayName in data)) || data.displayName is string)
            && (data.age is int || data.age is float)
            && data.timestamp is timestamp
            && data.options.a is bool
        );
      }

      allow read: if true;
      allow write: if ((matchesUser(uid) || isAdmin()) && __validator_0__(request.resource.data));

      match /posts/{postId} {
        function __validator_1__(data) {
          return ((
            data.type == "a"
              && (data.tags.size() == 0 || ((data.tags[0].id is int || data.tags[0].id is float) && data.tags[0].name is string))
              && data.text is string
          ) || (
            data.type == "b"
              && (data.tags.size() == 0 || ((data.tags[0].id is int || data.tags[0].id is float) && data.tags[0].name is string))
              && (data.texts.size() == 0 || data.texts[0] is string)
          ));
        }

        allow read: if true;
        allow write: if (matchesUser(uid) && __validator_1__(request.resource.data));
      }
    }
  }
}
```

### 3. コレクション・ドキュメントの操作

#### コントローラの初期化

先ほど定義したスキーマを使ってコントローラを初期化します。

Fireschema は **web と admin 両方**に対応しています。DocumentReference の `set()` の戻り値などは web と admin で型が異なりますが、`initFirestore()` の引数に応じて自動的に正しい型が付くようになっています。

```ts
import firebase, { firestore, initializeApp } from 'firebase' // または firebase-admin
import { schema } from '<スキーマファイルのパス>'

const app: firebase.app.App = initializeApp({
    // ...
})
const firestoreApp = app.firestore()

const $store: FirestoreController<
    typeof firestoreApp,
    typeof schema
> = initFirestore(firestore, firestoreApp, schema)
```

#### コレクションの参照・データ取得

コレクションの参照は `$store.collection(parent, name)` のような形で、parent は **`'root'`** か**親ドキュメント**を指定します。name は parent に応じて指定可能な値が変わるようになっています。

例えば上記のスキーマでは、

-   parent が `'root'` → スキーマの root には `users` しかないので name には `'users'` のみ指定可能
-   parent が `user` → スキーマの `user` 内には `posts` しかないので `'posts'` のみ指定可能

という感じです。存在しないコレクション名を指定するとエラーが出るので安全です。

```ts
const users = $store.collection('root', 'users') // /users
const user = users.ref.doc('userId') // /users/userId

const posts = $store.collection(user, 'posts') // /users/userId/posts
const post = posts.ref.doc('123') // /users/userId/posts/123

const postSnapshot = await post.get() // DocumentSnapshot<PostA | PostB>
```

スキーマで定義したとおり、`postData` は `PostA | PostB` という型になります。

コレクションやクエリの取得はこのように行います。collectionAdapter で定義したクエリは、`select` プロパティから使用できます。

```ts
const postsSnapshot = await posts.ref.get()
const techPostsSnapshot = await posts.select.byTag('tech').get()
```

`parentOfCollection()` を使うと、コレクションの親ドキュメントが型付きで取得できます。

```ts
const user = $store.parentOfCollection(posts.ref) // DocumentReference<User>
```

#### コレクショングループの参照・データ取得

コレクショングループの取得では、**`['users', 'posts']`** のように、コレクションのスキーマ上のパスを指定します。(「users と posts」という意味ではなく、「users 内の posts」という意味です)

```ts
const postsGroup = $store.collectionGroup(['users', 'posts'])
const techPostsSnapshot = await postsGroup.select.byTag('tech').get()
```

ここで注意が必要なのは、生の SDK での実際の collectionGroup の参照は **`collectionGroup('posts')`** になるということです。もし、`/users/{uid}/posts/{postId}` 以外の場所にも `posts` コレクションがあって (例えば `/posts/{postId}` など)、そのドキュメントデータの型が異なる場合、TypeScript の型情報と一致しないドキュメントも参照してしまうことになります。

これを防ぐには、場所ごとにコレクションの名前を unique にする必要があります。(例えば `/posts/{postId}` を `/sharedPosts/{postId}` にするなど)

#### ドキュメントの作成・更新

ドキュメントの作成は **`create()`** で行います。スキーマで指定した型、または `FieldValue` のみ指定できます。`set()` のラッパーなので、**すでにドキュメントが存在してもエラーにならない**点に注意してください。

```ts
await $store.create(user, {
    name: 'umi',
    displayName: null,
    age: 16,
    timestamp: $store.FieldValue.serverTimestamp(),
}
```

ドキュメントの更新は **`setMerge()`** か **`update()`** で行います。それぞれ `set(data, { merge: true })` と `update(data)` のラッパーで、データの型が **`Partial`** になっている点以外は `create()` と同じです。

トランザクション処理には `runTransaction` を使います。

```ts
await $store.runTransaction(async (tc) => {
    const snapshot = await tc.get(user)
    tc.setMerge(user, {
        age: snapshot.data()!.age + 1,
    })
})
```

## 内部実装について

### Custom Transformer

Custom Transformer では TypeScript の **Compiler API** と、そのラッパーである **ts-morph** を使って型情報の AST 解析を行い、rules の文字列に変換しています。

https://github.com/yarnaimo/fireschema/blob/master/src/_transformer/main.ts
https://github.com/yarnaimo/fireschema/blob/master/src/_transformer/document-schema.ts

### データの型付け

Fireschema では TypeScript の型システムを最大限活用して強力な型付け機能を提供しています。その中心となっているのが**ネストしたオブジェクトの特定のプロパティの型を再帰的に取得**する utility です。

```ts
type Next<U> = U extends readonly [string, ...string[]]
  ? ((...args: U) => void) extends (top: any, ...args: infer T) => void
    ? T
    : never
  : never

export type GetDeep<T, L extends readonly string[]> = L[0] extends keyof T
  ? {
      0: T[L[0]]
      1: GetDeep<T[L[0]], Next<L>>
    }[L[1] extends undefined ? 0 : 1]
  : L extends []
  ? T
  : never
```

詳細は省きますが、オブジェクトのプロパティのパスを配列で指定して型を取得できるようになっています。

```ts
type Foo = {
    a: {
        b: {
            c: number
        }
    }
}
type _ = GetDeep<Foo, ['a', 'b', 'c']> // Foo['a']['b']['c'] と同じ (number)
```

FirestoreController 経由で取得したドキュメントの参照には、データ自体の型に加えて `__loc__` というスキーマ上のパスを表すプロパティが型定義上でのみ設定されます (実際の値はセットしない)。

例えば `users` コレクションのドキュメントは **`DocumentReference<User & { __loc__: ['users'] }>`** という型になり、`GetDeep<typeof schema, ['users']>` のような形でスキーマから情報を取得します。

そして、その child である `posts` コレクションのドキュメントのスキーマ上のパスは、親のスキーマ上のパスとコレクション名を Variadic Tuple Types を使って結合した `['users', 'posts']` になるので、データの型は **`DocumentReference<(PostA | PostB) & { __loc__: ['users', 'posts'] }>`** のようになります。

さらに posts の child collection が存在した場合も、posts のスキーマ上のパス`['users', 'posts']` と子のコレクション名を結合した `['users', 'posts', 'collectionName']` …… というように、ネストしたコレクションでもスキーマ情報が取得できるようになっています。

```ts
type ParentLoc = ['users']
type Name = 'posts'
type Loc = [...ParentLoc, Name] // => ['users', 'posts']
```

## 今後の予定

-   [x] `documentSchema()` でオブジェクトの子プロパティのスキーマも指定できるようにする (現在は `'map'` しか指定できないので子プロパティのスキーマ検証ができない)
-   [ ] 数値の範囲など、任意のセキュリティルールを設定できるようにする
-   [ ] `firestore.indexes.json` を自動生成する
-   [x] トランザクション処理をわかりやすくする

## まとめ

ここまで「Firestore を型安全にするライブラリ」として紹介してきた Fireschema ですが、実は Cloud Functions の Callable Function のリクエスト・レスポンスや PubSub Topic などを型安全にする機能も持っています。使い方などはこちらをご覧ください ↓

https://github.com/yarnaimo/fireschema/blob/master/src/__tests__/_fixtures/functions-schema.ts
https://github.com/yarnaimo/fireschema/blob/master/src/__tests__/_infrastructure/functions-server.ts

Fireschema を使うと、Firestore での開発を効率よく安全に行うことができます。興味のある方はぜひ使ってみてください。

特に Firestore の型付けの方は型定義が複雑すぎるなど改善できる点もありそうなので、皆さまのコントリビューションもお待ちしています。

https://github.com/yarnaimo/fireschema

Twitter: [@yarnaimo](https://twitter.com/yarnaimo)