-
8/18 更新
- Custom Transformer を使用し、
documentSchema<T>()
の型引数 (T
の部分) の AST 解析によって rules の文字列が自動生成できるようになったため、documentSchema()
の引数が不要になりました
- Custom Transformer を使用し、
スキーマレスな Firestore では、その気になれば一般ユーザーが予期しない構造のデータをセットできてしまうので、セキュリティルールで書き込み時のスキーマ検証を設定する必要があります。しかし、セキュリティルールはモデル定義に合わせて手動で書かなければならないのでミスが起きやすく、そのためにテストを書くのもコストに見合わない感があります。
例: User
型のモデルとそのスキーマ検証を行う関数
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 の生成やドキュメントの型付けなどを自動で行い、これらの問題を解決します。
TypeScriptを使った開発でFirestoreを型安全にするライブラリ Fireschema を作りました!!🎉
— やまいも (@yarnaimo) August 12, 2020
コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成やドキュメントの型付けなどを自動で行います!https://t.co/rFgnJUPSV9
インストール
Fireschema は Variadic Tuple Types を使用しているため TypeScript 4.0 以上 のみサポートしています。(TypeScript 4.0 は現在 β 版です)
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
が指定できます。
{
"scripts": {
"build": "ttsc", // <- tsc
"ts-node": "ts-node --compiler ttypescript" // <- ts-node
}
}
tsconfig.json
{
"compilerOptions": {
"plugins": [
{
"transform": "fireschema/transformer"
}
]
}
}
jest.config.js
module.exports = {
globals: {
'ts-jest': {
tsConfig: 'tsconfig.json',
compiler: 'ttypescript',
},
},
}
Override Dependencies
fireschema が依存する一部のパッケージは TypeScript 3.9 に依存しているため、Selective dependency resolutions で依存関係を上書きする必要があります。(yarn のみ対応)
{
"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
型のモデルとそのスキーマ検証を行う関数
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 の生成やドキュメントの型付けなどを自動で行い、これらの問題を解決します。
TypeScriptを使った開発でFirestoreを型安全にするライブラリ Fireschema を作りました!!🎉
— やまいも (@yarnaimo) August 12, 2020
コレクション構造・スキーマ・アクセス制御などをオブジェクト形式で定義するだけで、firestore.rules の生成やドキュメントの型付けなどを自動で行います!https://t.co/rFgnJUPSV9
インストール
Fireschema は Variadic Tuple Types を使用しているため TypeScript 4.0 以上 のみサポートしています。(TypeScript 4.0 は現在 β 版です)
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
が指定できます。
{
"scripts": {
"build": "ttsc", // <- tsc
"ts-node": "ts-node --compiler ttypescript" // <- ts-node
}
}
tsconfig.json
{
"compilerOptions": {
"plugins": [
{
"transform": "fireschema/transformer"
}
]
}
}
jest.config.js
module.exports = {
globals: {
'ts-jest': {
tsConfig: 'tsconfig.json',
compiler: 'ttypescript',
},
},
}
Override Dependencies
fireschema が依存する一部のパッケージは TypeScript 3.9 に依存しているため、Selective dependency resolutions で依存関係を上書きする必要があります。(yarn のみ対応)
{
"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
の例を参照)
例
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 してください。
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_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()
の引数に応じて自動的に正しい型が付くようになっています。
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'
のみ指定可能
という感じです。存在しないコレクション名を指定するとエラーが出るので安全です。
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
プロパティから使用できます。
const postsSnapshot = await posts.ref.get()
const techPostsSnapshot = await posts.select.byTag('tech').get()
parentOfCollection()
を使うと、コレクションの親ドキュメントが型付きで取得できます。
const user = $store.parentOfCollection(posts.ref) // DocumentReference<User>
コレクショングループの参照・データ取得
コレクショングループの取得では、['users', 'posts']
のように、コレクションのスキーマ上のパスを指定します。(「users と posts」という意味ではなく、「users 内の posts」という意味です)
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()
のラッパーなので、すでにドキュメントが存在してもエラーにならない点に注意してください。
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
を使います。
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 です。
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
詳細は省きますが、オブジェクトのプロパティのパスを配列で指定して型を取得できるようになっています。
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']
…… というように、ネストしたコレクションでもスキーマ情報が取得できるようになっています。
type ParentLoc = ['users']
type Name = 'posts'
type Loc = [...ParentLoc, Name] // => ['users', 'posts']
今後の予定
-
documentSchema()
でオブジェクトの子プロパティのスキーマも指定できるようにする (現在は'map'
しか指定できないので子プロパティのスキーマ検証ができない) - 数値の範囲など、任意のセキュリティルールを設定できるようにする
-
firestore.indexes.json
を自動生成する - トランザクション処理をわかりやすくする
まとめ
ここまで「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 の型付けの方は型定義が複雑すぎるなど改善できる点もありそうなので、皆さまのコントリビューションもお待ちしています。
Twitter: @yarnaimo