# Youtubeでサジェストされた『Typescript GraphQL CRUD Tutorialをやってみました』をやってみました
なんとなくイメージ的にTypescriptの型とGraphQLのスキーマ定義とか型定義って相性が良さそうかな、とか思ってみかけたそれっぽいチュートリアルを試してみることにしました。
# プロジェクトの作成
👇こんな感じでプロジェクトを作っていきます。
npx create-graphql-api typescript-crud-example
create-graphql-apiはGitHub上にあるので、中身はすぐ確認できますが、これを叩くと👇こんな感じのプロジェクトが生成されて、
$ ls -l
total 320
drwxr-xr-x 416 eiji staff 13312 6 23 23:38 node_modules
-rw-r--r-- 1 eiji staff 887 6 23 23:38 ormconfig.js
-rw-r--r-- 1 eiji staff 555 6 23 23:38 package.json
drwxr-xr-x 5 eiji staff 160 6 23 23:38 src
-rw-r--r-- 1 eiji staff 817 6 23 23:38 tsconfig.json
-rw-r--r-- 1 eiji staff 151093 6 23 23:38 yarn.lock
package.jsonは👇こんな感じで、expressでGraphQLのApollo serverとか、sqlite3(本番環境とかだったらPostgreSQLとかの方がいいかもね)とtypeormで〜。とかってのが見て取れます。そしてtype-graphqlを使うとTypescriptでGraphQL扱うのが簡単、とか。
{
"name": "myapi",
"version": "0.0.1",
"devDependencies": {
"@types/express": "^4.17.2",
"@types/node": "^12.12.8",
"nodemon": "^1.19.4",
"ts-node": "8.5.2",
"typescript": "3.7.2"
},
"dependencies": {
"apollo-server-express": "^2.9.9",
"express": "^4.17.1",
"graphql": "^14.5.8",
"pg": "^7.13.0",
"reflect-metadata": "^0.1.10",
"sqlite3": "^4.1.0",
"type-graphql": "^0.17.5",
"typeorm": "0.2.20"
},
"scripts": {
"start": "nodemon --exec ts-node src/index.ts",
"build": "tsc"
}
}
でもって、yarn startしてやると、
$ yarn start
yarn run v1.19.1
warning package.json: No license field
$ nodemon --exec ts-node src/index.ts
[nodemon] 1.19.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching dir(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node src/index.ts`
query: BEGIN TRANSACTION
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" IN ('user')
query: SELECT * FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" IN ('user')
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'typeorm_metadata'
query: CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar NOT NULL, "lastName" varchar NOT NULL, "age" integer NOT NULL)
query: COMMIT
server started at http://localhost:4000/graphql
👇いきなりこんなところまで出来ちゃう優れもの。
ormconfig.jsはDB周りの設定。developmentはsqliteでproductionはpostgresとか。
module.exports = [
{
name: "development",
type: "sqlite",
database: "database.sqlite",
synchronize: true,
logging: true,
entities: ["src/entity/**/*.ts"],
migrations: ["src/migration/**/*.ts"],
subscribers: ["src/subscriber/**/*.ts"],
cli: {
entitiesDir: "src/entity",
migrationsDir: "src/migration",
subscribersDir: "src/subscriber"
}
},
{
name: "production",
type: "postgres",
url: process.env.DATABASE_URL,
synchronize: true, // switch this to false once you have the initial tables created and use migrations instead
logging: false,
entities: ["dist/entity/**/*.js"],
migrations: ["dist/migration/**/*.js"],
subscribers: ["dist/subscriber/**/*.js"],
cli: {
entitiesDir: "dist/entity",
migrationsDir: "dist/migration",
subscribersDir: "dist/subscriber"
}
}
];
そして、index.tsファイル。ちょっとYoutubeのビデオと今の最新とでコードが変わってるところがあるけど、いわゆるサーバープログラムというか、expressでGraphQLのApolloサーバーを起動して4000版ポートでリスンしますよ、と。そして、GraphQL的には後で見ていくHelloWorldResolverだけが登録されている状態。
(async () => {
const app = express();
const options = await getConnectionOptions(
process.env.NODE_ENV || "development"
);
await createConnection({ ...options, name: "default" });
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [HelloWorldResolver],
validate: true
}),
context: ({ req, res }) => ({ req, res })
});
apolloServer.applyMiddleware({ app, cors: false });
const port = process.env.PORT || 4000;
app.listen(port, () => {
console.log(`server started at http://localhost:${port}/graphql`);
});
})();
# HelloWorldResolverリゾルバとUserエンティティ
GenerateされたHelloWorldResolver.tsは👇こんな感じになっていて、クエリ叩くとただhi!って返ってくるだけ。
import { Query, Resolver } from "type-graphql";
@Resolver()
export class HelloWorldResolver {
@Query(() => String)
hello() {
return "hi!";
}
}
User.tsは、Javaプログラマだったら、昔よくこんなコード書いたよなーってなるやつ?(笑)
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
age: number;
}
# 動作確認
localhost:4000/graphqlで画面を開いて、helloをクエリしてやるとhi!が返ってきたらとりあえず動いてそうですかね、と。
# Movieエンティティの追加
entityディレクトリの中にMovie.tsファイルを作っていきます。それ用のVS Codeのプラグイン入れたらガンガン補完効くし👇みたいなあんまり気分の乗らないクラスを作るのもサクっとイケる。
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
@Entity()
export class Movie {
@PrimaryGeneratedColumn()
id: number
@Column()
title: string
@Column('int', { default: 60 })
minutes: number
}
# Movieリゾルバの追加
続いてresolversフォルダにMovieResolver.tsを作成して、新しい映画登録用のMutationを作っていきます。
import { Resolver, Mutation } from "type-graphql";
@Resolver()
export class MovieResolver {
@Mutation()
createMovie() {
}
}
そして、index.tsのApolloサーバーのリゾルバにMovieResolverを追加。
import { MovieResolver } from "./resolvers/MovieResolver";
〜略〜
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [HelloWorldResolver, MovieResolver],
validate: true
}),
GraphQLの名前とTypescriptでの名前と〜とか、nullを許可するとかしないとかって話もありつつ、出来上がったcreateMovieのMutationは👇こちら。
import { Resolver, Mutation, Arg, Int } from "type-graphql";
import { Movie } from "src/entity/Movie";
@Resolver()
export class MovieResolver {
@Mutation(() => Boolean)
async createMovie(
@Arg("title") title: string,
@Arg("minutes", () => Int) minutes: number
) {
await Movie.insert({title, minutes})
return true;
}
}
そして、entityのMovie.tsで、extends BaseEntityしてやることによって👆のMovie.insertっていうのが使えるようになる的な感じです。
@Entity()
export class Movie extends BaseEntity {
サーバーのコメントも👇のようにMovie感が出ております。
[nodemon] starting `ts-node src/index.ts`
query: BEGIN TRANSACTION
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" IN ('movie', 'user')
query: SELECT * FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" IN ('movie', 'user')
query: PRAGMA table_info("user")
query: PRAGMA index_list("user")
query: PRAGMA foreign_key_list("user")
query: PRAGMA table_info("movie")
query: PRAGMA index_list("movie")
query: PRAGMA foreign_key_list("movie")
query: SELECT * FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'typeorm_metadata'
query: COMMIT
# GraphQLのMutationでデータを登録してみる
👇のようなmutationを発行すると
mutation{
createMovie(
title: "hoge the movie",
minutes: 30
)
}
👇のようにtrueが返ってきています。
が、これだと中身が分からないので、リゾルバにQueryを追加していきます。そしてリゾルバに手を入れる前にentityにField定義を入れてあげることによってリゾルバでそのモデルを直接使用できる、と。あー、あとObjectType。(日本語で書くとヤヤコシイけどentityの各項目👇のようなアノテーションを追加するだけ。DBの型とGraphQLの型と〜っていう)
@ObjectType()
@Entity()
export class Movie extends BaseEntity {
@Field(() => Int)
@PrimaryGeneratedColumn()
id: number
@Field()
@Column()
title: string
@Field(() => Int)
@Column('int', { default: 60 })
minutes: number
}
# ということでQuery
MovieResolver.tsのQueryは👇のような感じ。
@Query(() => [Movie])
movies() {
return Movie.find();
}
そして上記でCreateしたレコードが取得できました👇
# 続いてMovieInputというクラスをMovieResolverの中に作る
言ってることは分かるんだけど、entityの他にまた定義だけのクラスが出てくるとかちょっと萎える感あるけど👇MovieInputというクラスを作っていきます。
@InputType()
class MovieInput {
@Field()
title: string
@Field(() => Int)
minutes: number
}
そうすると👇ほら、CreateのMutationがスッキリしたよね的な。
@Mutation(() => Boolean)
async createMovie(@Arg("options", () => MovieInput) options: MovieInput) {
await Movie.insert(options);
return true;
}
👇optionsでMutationを発行してデータを登録できました。
今まではCreateのreturnでBooleanしか返してなかったけど、それだとイマイチなので、insertではなくcreateを使って👇こんな風にMovieオブジェクトを返してやるようにします、と。
@Mutation(() => Movie)
async createMovie(@Arg("options", () => MovieInput) options: MovieInput) {
const movie = await Movie.create(options).save();
return movie;
}
そうすると👇のようにGraphQLで戻り値としてid, title, minutesが受け取れるようになります。これはちょっと便利感あるかも。
もちろん、この段階でQueryを投げると登録した3件が全て返ってきます。
# UpdateのMutationを作っていきます
更新のMutationはidを指定してMovieInputで〜という形にするので👇こんな感じで。
@Mutation(() => Boolean)
async updateMovie(@Arg('id') id: number,
@Arg("input", () => MovieInput) input: MovieInput) {
await Movie.update({id}, input)
return true;
}
👇3つめのレコードをアップデートしてみました。
クエリを叩いても👇3つめがアップデートされています。
ただ、これだとtitleとminutesのどちらか一方だけを入力したい時にそれが出来なくなってしまいます、と。なので、nullを許可するMovieUpdateInputというクラスを作って〜って、、かったるいな…笑
@InputType()
class MovieUpdateInput {
@Field(() => String, {nullable: true})
title?: string;
@Field(() => Int, {nullable: true})
minutes?: number;
}
Mutationの方も使うInputクラスをMovieUpdateInputに書き換え。これで片方だけ指定されても更新出来るようになりました。
@Mutation(() => Boolean)
async updateMovie(@Arg('id') id: number,
@Arg("input", () => MovieUpdateInput) input: MovieUpdateInput) {
await Movie.update({id}, input)
return true;
}
# 最後はDelete
idを指定して消すだけなのでお手軽。
@Mutation(() => Boolean)
async deleteMovie(
@Arg("id", () => Int) id: number
) {
await Movie.delete({id});
return true;
}
ということで、GraphQLでデータを消してみて👇
Queryで消えてることが確認できました👇
こういうのは習うより慣れろ感がある気がするので、とてもありがたいコンテンツでございました。@benawadさん、ありがとうございました。