diff --git a/.github/tests/orm/graphql-gqloom/run.sh b/.github/tests/orm/graphql-gqloom/run.sh new file mode 100755 index 000000000000..a6a2f7270fe1 --- /dev/null +++ b/.github/tests/orm/graphql-gqloom/run.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -eu + +echo "๐Ÿ” Starting test setup for graphql-gqloom..." + +echo "๐Ÿ“‚ Current working directory before REPO_ROOT: $(pwd)" +echo "๐Ÿ“ Listing contents:" +ls -la + +REPO_ROOT="$(git rev-parse --show-toplevel)" +echo "๐Ÿ“Œ Detected repo root: $REPO_ROOT" + +cd "$REPO_ROOT/orm/graphql-gqloom" +echo "๐Ÿ“‚ Changed directory to: $(pwd)" + +echo "๐Ÿ“ฆ Installing test deps..." +npm install + +# Go to Node script dir and install its deps +NODE_SCRIPT_DIR="../../.github/get-ppg-dev" +pushd "$NODE_SCRIPT_DIR" > /dev/null +npm install + +# Start Prisma Dev server +LOG_FILE="./ppg-dev-url.log" +rm -f "$LOG_FILE" +touch "$LOG_FILE" + +echo "๐Ÿš€ Starting Prisma Dev in background..." +node index.js >"$LOG_FILE" & +NODE_PID=$! + +# Wait for DATABASE_URL +echo "๐Ÿ”Ž Waiting for Prisma Dev to emit DATABASE_URL..." +MAX_WAIT=60 +WAITED=0 +until grep -q '^prisma+postgres://' "$LOG_FILE"; do + sleep 1 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "โŒ Timeout waiting for DATABASE_URL" + cat "$LOG_FILE" + kill "$NODE_PID" || true + exit 1 + fi +done + +DB_URL=$(grep '^prisma+postgres://' "$LOG_FILE" | tail -1) +export DATABASE_URL="$DB_URL" +echo "โœ… DATABASE_URL: $DATABASE_URL" + +popd > /dev/null # Back to orm/graphql-gqloom + +# Run migrations and seed +npx prisma migrate reset --force --skip-seed +npx prisma migrate dev --name init +npx prisma db seed + +# Start the app +npm run dev & +pid=$! + +# Wait for app to be ready +echo "๐Ÿ”Ž Waiting for app to be ready..." +MAX_WAIT=60 +WAITED=0 +until curl -s http://localhost:4000/ > /dev/null 2>&1; do + sleep 1 + WAITED=$((WAITED + 1)) + if [ "$WAITED" -ge "$MAX_WAIT" ]; then + echo "โŒ Timeout waiting for app to start" + kill "$pid" 2>/dev/null || true + kill "$NODE_PID" 2>/dev/null || true + exit 1 + fi +done +echo "โœ… App is ready" + +# Run GraphQL Postman collection +echo "๐Ÿงช Running Postman tests..." +npx newman run "$REPO_ROOT/.github/tests/postman_collections/graphql.json" --bail + +# Cleanup +echo "๐Ÿ›‘ Shutting down app (PID $pid) and Prisma Dev (PID $NODE_PID)..." +kill "$pid" 2>/dev/null || true +kill "$NODE_PID" +wait "$NODE_PID" 2>/dev/null || true +wait "$pid" 2>/dev/null || true diff --git a/orm/graphql-gqloom/.gitignore b/orm/graphql-gqloom/.gitignore new file mode 100644 index 000000000000..da5b597e73cc --- /dev/null +++ b/orm/graphql-gqloom/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.env* +src/generated \ No newline at end of file diff --git a/orm/graphql-gqloom/.nvmrc b/orm/graphql-gqloom/.nvmrc new file mode 100644 index 000000000000..deed13c0169b --- /dev/null +++ b/orm/graphql-gqloom/.nvmrc @@ -0,0 +1 @@ +lts/jod diff --git a/orm/graphql-gqloom/README.md b/orm/graphql-gqloom/README.md new file mode 100644 index 000000000000..7170185a4917 --- /dev/null +++ b/orm/graphql-gqloom/README.md @@ -0,0 +1,653 @@ +# GraphQL Server Example with GraphQL Yoga & Prisma Postgres & GQLoom + +This example shows how to implement a **GraphQL server with TypeScript** with the following stack: + +- [**GraphQL Yoga**](https://the-guild.dev/graphql/yoga-server): GraphQL server +- [**GQLoom**](https://gqloom.dev/): Code-First GraphQL Schema library that weaves TypeScript runtime types into GraphQL schemas +- [**Zod**](https://zod.dev/): Schema validation library for input types +- [**Prisma Client**](https://www.prisma.io/docs/concepts/components/prisma-client): Databases access (ORM) +- [**Prisma Migrate**](https://www.prisma.io/docs/concepts/components/prisma-migrate): Database migrations +- [**Prisma Postgres**](https://www.prisma.io/postgres): A serverless PostgreSQL database built on unikernels. + +## Getting started + +### 1. Download example and navigate into the project directory + +Download this example: + +``` +npx try-prisma@latest --template orm/graphql-gqloom --install npm --name graphql-gqloom +``` + +Then, navigate into the project directory: + +``` +cd graphql-gqloom +``` + +
Alternative: Clone the entire repo + +Clone this repository: + +```terminal +git clone git@github.com:prisma/prisma-examples.git --depth=1 +``` + +Install npm dependencies: + +```terminal +cd prisma-examples/orm/graphql-gqloom +npm install +``` + +
+ +### 2. Create and seed the database + +Create a new [Prisma Postgres](https://www.prisma.io/docs/postgres/overview) database by executing: + +```terminal +npx prisma init --db +``` + +If you don't have a [Prisma Data Platform](https://console.prisma.io/) account yet, or if you are not logged in, the command will prompt you to log in using one of the available authentication providers. A browser window will open so you can log in or create an account. Return to the CLI after you have completed this step. + +Once logged in (or if you were already logged in), the CLI will prompt you to: +1. Select a **region** (e.g. `us-east-1`) +1. Enter a **project name** + +After successful creation, you will see output similar to the following: + +
+ +CLI output + +```terminal +Let's set up your Prisma Postgres database! +? Select your region: ap-northeast-1 - Asia Pacific (Tokyo) +? Enter a project name: testing-migration +โœ” Success! Your Prisma Postgres database is ready โœ… + +We found an existing schema.prisma file in your current project directory. + +--- Database URL --- + +Connect Prisma ORM to your Prisma Postgres database with this URL: + +prisma+postgres://accelerate.prisma-data.net/?api_key=... + +--- Next steps --- + +Go to https://pris.ly/ppg-init for detailed instructions. + +1. Install and use the Prisma Accelerate extension +Prisma Postgres requires the Prisma Accelerate extension for querying. If you haven't already installed it, install it in your project: +npm install @prisma/extension-accelerate + +...and add it to your Prisma Client instance: +import { withAccelerate } from "@prisma/extension-accelerate" + +const prisma = new PrismaClient().$extends(withAccelerate()) + +2. Apply migrations +Run the following command to create and apply a migration: +npx prisma migrate dev + +3. Manage your data +View and edit your data locally by running this command: +npx prisma studio + +...or online in Console: +https://console.prisma.io/{workspaceId}/{projectId}/studio + +4. Send queries from your app +If you already have an existing app with Prisma ORM, you can now run it and it will send queries against your newly created Prisma Postgres instance. + +5. Learn more +For more info, visit the Prisma Postgres docs: https://pris.ly/ppg-docs +``` + +
+ +Locate and copy the database URL provided in the CLI output. Then, create a `.env` file in the project root: + +```bash +touch .env +``` + +Now, paste the URL into it as a value for the `DATABASE_URL` environment variable. For example: + +```bash +# .env +DATABASE_URL=prisma+postgres://accelerate.prisma-data.net/?api_key=ey... +``` + +Run the following command to create tables in your database. This creates the `User` and `Post` tables that are defined in [`prisma/schema.prisma`](./prisma/schema.prisma): + +```terminal +npx prisma migrate dev --name init +``` + +Execute the seed file in [`prisma/seed.ts`](./prisma/seed.ts) to populate your database with some sample data, by running: + +```terminal +npx prisma db seed +``` + +### 3. Start the GraphQL server + +Launch your GraphQL server with this command: + +``` +npm run dev +``` + +Navigate to [http://localhost:4000](http://localhost:4000) in your browser to explore the API of your GraphQL server in a [GraphQL Playground](https://github.com/prisma/graphql-playground). + + +## Using the GraphQL API + +The schema that specifies the API operations of your GraphQL server is defined in the resolvers located in [`./src/resolvers/`](./src/resolvers/). Below are a number of operations that you can send to the API using the GraphQL Playground. + +Feel free to adjust any operation by adding or removing fields. The GraphQL Playground helps you with its auto-completion and query validation features. + +### Retrieve all published posts and their authors + +```graphql +query { + feed { + id + title + content + published + author { + id + name + email + } + } +} +``` + +
See more API operations + +### Retrieve the drafts of a user + +```graphql +{ + draftsByUser( + userUniqueInput: { + email: "mahmoud@prisma.io" + } + ) { + id + title + content + published + author { + id + name + email + } + } +} +``` + + +### Create a new user + +```graphql +mutation { + signupUser(data: { name: "Sarah", email: "sarah@prisma.io" }) { + id + } +} +``` + +### Create a new draft + +```graphql +mutation { + createDraft( + data: { title: "Join the Prisma Discord", content: "https://pris.ly/discord" } + authorEmail: "alice@prisma.io" + ) { + id + viewCount + published + author { + id + name + } + } +} +``` + +### Publish/unpublish an existing post + +```graphql +mutation { + togglePublishPost(id: __POST_ID__) { + id + published + } +} +``` + +Note that you need to replace the `__POST_ID__` placeholder with an actual `id` from a `Post` record in the database, e.g.`5`: + +```graphql +mutation { + togglePublishPost(id: 5) { + id + published + } +} +``` + +### Increment the view count of a post + +```graphql +mutation { + incrementPostViewCount(id: __POST_ID__) { + id + viewCount + } +} +``` + +Note that you need to replace the `__POST_ID__` placeholder with an actual `id` from a `Post` record in the database, e.g.`5`: + +```graphql +mutation { + incrementPostViewCount(id: 5) { + id + viewCount + } +} +``` + +### Search for posts that contain a specific string in their title or content + +```graphql +{ + feed( + searchString: "prisma" + ) { + id + title + content + published + } +} +``` + +### Paginate and order the returned posts + +```graphql +{ + feed( + skip: 2 + take: 2 + orderBy: { updatedAt: desc } + ) { + id + updatedAt + title + content + published + } +} +``` + +### Retrieve a single post + +```graphql +{ + postById(id: __POST_ID__ ) { + id + title + content + published + } +} +``` + +Note that you need to replace the `__POST_ID__` placeholder with an actual `id` from a `Post` record in the database, e.g.`5`: + +```graphql +{ + postById(id: 5 ) { + id + title + content + published + } +} +``` + +### Delete a post + +```graphql +mutation { + deletePost(id: __POST_ID__) { + id + } +} +``` + +Note that you need to replace the `__POST_ID__` placeholder with an actual `id` from a `Post` record in the database, e.g.`5`: + +```graphql +mutation { + deletePost(id: 5) { + id + } +} +``` + +
+ + +## Evolving the app + +Evolving the application typically requires two steps: + +1. Migrate your database using Prisma Migrate +1. Update your application code + +For the following example scenario, assume you want to add a "profile" feature to the app where users can create a profile and write a short bio about themselves. + +### 1. Migrate your database using Prisma Migrate + +The first step is to add a new table, e.g. called `Profile`, to the database. You can do this by adding a new model to your [Prisma schema file](./prisma/schema.prisma) file and then running a migration afterwards: + +```diff +// ./prisma/schema.prisma + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + posts Post[] ++ profile Profile? +} + +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String? + published Boolean @default(false) + viewCount Int @default(0) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? +} + ++model Profile { ++ id Int @id @default(autoincrement()) ++ bio String? ++ user User @relation(fields: [userId], references: [id]) ++ userId Int @unique ++} +``` + +Once you've updated your data model, you can execute the changes against your database with the following command: + +``` +npx prisma migrate dev --name add-profile +``` + +This adds another migration to the `prisma/migrations` directory and creates the new `Profile` table in the database. + +### 2. Update your application code + +You can now use your `PrismaClient` instance to perform operations against the new `Profile` table. Those operations can be used to implement queries and mutations in the GraphQL API. + +#### 2.1. Add the `Profile` type to your GLoom schema + +First, create a new `profile.ts` file in `src/resolvers/` and define the Profile resolver using GLoom's declarative syntax: + +```diff +// ./src/resolvers/profile.ts +import { field, mutation, query, resolver } from '@gqloom/core' +import * as z from 'zod' +import { Profile } from '../generated/gqloom' +import { PrismaResolverFactory } from '@gqloom/prisma' +import { prisma } from '../db' + +const profileFactory = new PrismaResolverFactory(Profile, prisma) + +export const profileResolver = resolver.of(Profile, { + userId: field.hidden, + user: profileFactory.relationField('user'), + + createProfile: mutation(Profile) + .input({ + userId: z.number().int(), + bio: z.string().nullish(), + }) + .resolve(({ userId, bio }) => { + return prisma.profile.create({ + data: { + bio: bio ?? undefined, + user: { + connect: { id: userId }, + }, + }, + }) + }), +}) +``` + +Then update your `server.ts` to include the new resolver: + +```diff +// ./src/server.ts +import { weave } from '@gqloom/core' +import { PrismaWeaver } from '@gqloom/prisma' +import { postResolver } from './resolvers/post' +import { userResolver } from './resolvers/user' +import { profileResolver } from './resolvers/profile' + +const schema = weave( + ZodWeaver, + PrismaWeaver.config({ ... }), + userResolver, + postResolver, + profileResolver, +) +``` + +Also, update the `User` resolver to include the profile relation: + +```diff +// ./src/resolvers/user.ts + +export const userResolver = resolver.of(User, { + // ... existing fields ... + profile: userFactory.relationField('profile'), + + // ... existing queries and mutations ... +}) +``` + +#### 2.2. Add a `createProfile` GraphQL mutation + +The `createProfile` mutation was already added in the `profileResolver` above, but you can also update the User resolver to expose it directly: + +```graphql +mutation { + createProfile( + userId: 1 + bio: "I like turtles" + ) { + id + bio + user { + id + name + } + } +} +``` + +
Expand to view more sample Prisma Client queries on Profile + +Here are some more sample Prisma Client queries on the new Profile model: + +##### Create a new profile for an existing user + +```ts +const profile = await prisma.profile.create({ + data: { + bio: 'Hello World', + user: { + connect: { id: 1 }, + }, + }, +}) +``` + +##### Create a new user with a new profile + +```ts +const user = await prisma.user.create({ + data: { + email: 'john@prisma.io', + name: 'John', + profile: { + create: { + bio: 'Hello World', + }, + }, + }, +}) +``` + +##### Update the profile of an existing user + +```ts +const userWithUpdatedProfile = await prisma.user.update({ + where: { id: 1 }, + data: { + profile: { + update: { + bio: 'Hello Friends', + }, + }, + }, +}) +``` + +
+ +## Switch to another database (e.g. SQLite, MySQL, SQL Server, MongoDB) + +If you want to try this example with another database than Postgres, you can adjust the the database connection in [`prisma/schema.prisma`](./prisma/schema.prisma) by reconfiguring the `datasource` block. + +Learn more about the different connection configurations in the [docs](https://www.prisma.io/docs/reference/database-reference/connection-urls). + +
Expand for an overview of example configurations with different databases + +### Remove the Prisma Client extension + +Before you proceed to use your own database, you should remove the Prisma client extension required for Prisma Postgres: + +```terminal +npm uninstall @prisma/extension-accelerate +``` + +Remove the client extension from your `PrismaClient`: + +```diff +- const prisma = new PrismaClient().$extends(withAccelerate()) ++ const prisma = new PrismaClient() +``` + +### Your own PostgreSQL database + +To use your own PostgreSQL database remove the `@prisma/extension-accelerate` package and remove the Prisma client extension. + +### SQLite + +Modify the `provider` value in the `datasource` block in the [`prisma.schema`](./prisma/schema.prisma) file: + +```prisma +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} +``` + +Create an `.env` file and add the SQLite database connection string in it. For example: + +```terminal +DATABASE_URL="file:./dev.db" +``` + +### MySQL + +Modify the `provider` value in the `datasource` block in the [`prisma.schema`](./prisma/schema.prisma) file: + +```prisma +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} +``` + +Create an `.env` file and add a MySQL database connection string in it. For example: + +```terminal +## This is a placeholder url +DATABASE_URL="mysql://janedoe:mypassword@localhost:3306/notesapi" +``` + +### Microsoft SQL Server + +Modify the `provider` value in the `datasource` block in the [`prisma.schema`](./prisma/schema.prisma) file: + +```prisma +datasource db { + provider = "sqlserver" + url = env("DATABASE_URL") +} +``` + +Create an `.env` file and add a Microsoft SQL Server database connection string in it. For example: + +```terminal +## This is a placeholder url +DATABASE_URL="sqlserver://localhost:1433;initial catalog=sample;user=sa;password=mypassword;" +``` + +### MongoDB + +Modify the `provider` value in the `datasource` block in the [`prisma.schema`](./prisma/schema.prisma) file: + +```prisma +datasource db { + provider = "mongodb" + url = env("DATABASE_URL") +} +``` + +Create an `.env` file and add a local MongoDB database connection string in it. For example: + +```terminal +## This is a placeholder url +DATABASE_URL="mongodb://USERNAME:PASSWORD@HOST/DATABASE?authSource=admin&retryWrites=true&w=majority" +``` + +
+ +## Next steps + +- Check out the [Prisma docs](https://www.prisma.io/docs) +- Learn more about [GLoom](https://gqloom.dev/) +- [Join our community on Discord](https://pris.ly/discord?utm_source=github&utm_medium=prisma_examples&utm_content=next_steps_section) to share feedback and interact with other users. +- [Subscribe to our YouTube channel](https://pris.ly/youtube?utm_source=github&utm_medium=prisma_examples&utm_content=next_steps_section) for live demos and video tutorials. +- [Follow us on X](https://pris.ly/x?utm_source=github&utm_medium=prisma_examples&utm_content=next_steps_section) for the latest updates. +- Report issues or ask [questions on GitHub](https://pris.ly/github?utm_source=github&utm_medium=prisma_examples&utm_content=next_steps_section). diff --git a/orm/graphql-gqloom/package.json b/orm/graphql-gqloom/package.json new file mode 100644 index 000000000000..f25ec249a85e --- /dev/null +++ b/orm/graphql-gqloom/package.json @@ -0,0 +1,31 @@ +{ + "name": "typescript-graphql-gqloom", + "private": true, + "type": "commonjs", + "scripts": { + "dev": "tsx watch --env-file=.env src/server.ts", + "start": "tsx --env-file=.env src/server.ts", + "generate": "prisma generate" + }, + "devDependencies": { + "@types/node": "^24.8.1", + "prisma": "^6.17.1", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "dependencies": { + "@gqloom/core": "^0.12.1", + "@gqloom/prisma": "^0.12.2", + "@gqloom/zod": "^0.12.2", + "@prisma/adapter-pg": "^6.17.1", + "@prisma/client": "^6.17.1", + "@prisma/extension-accelerate": "^2.0.2", + "graphql": "^16.11.0", + "graphql-scalars": "^1.25.0", + "graphql-yoga": "^5.16.0", + "zod": "^4.1.12" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + } +} diff --git a/orm/graphql-gqloom/prisma/schema.prisma b/orm/graphql-gqloom/prisma/schema.prisma new file mode 100644 index 000000000000..90127c2b1eb1 --- /dev/null +++ b/orm/graphql-gqloom/prisma/schema.prisma @@ -0,0 +1,34 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client" + engineType = "client" + output = "../src/generated/prisma" +} + +generator gqloom { + provider = "prisma-gqloom" + output = "../src/generated/gqloom" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String? + published Boolean @default(false) + viewCount Int @default(0) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? +} diff --git a/orm/graphql-gqloom/prisma/seed.ts b/orm/graphql-gqloom/prisma/seed.ts new file mode 100644 index 000000000000..a9240c2ac3b3 --- /dev/null +++ b/orm/graphql-gqloom/prisma/seed.ts @@ -0,0 +1,73 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { type Prisma, PrismaClient } from '../src/generated/prisma/client' +import { withAccelerate } from '@prisma/extension-accelerate' +import { prisma } from '../src/db' + +const userData: Prisma.UserCreateInput[] = [ + { + name: 'Alice', + email: 'alice@prisma.io', + posts: { + create: [ + { + title: 'Join the Prisma Discord', + content: 'https://pris.ly/discord', + published: true, + }, + ], + }, + }, + { + name: 'Nilu', + email: 'nilu@prisma.io', + posts: { + create: [ + { + title: 'Follow Prisma on Twitter', + content: 'https://www.twitter.com/prisma', + published: true, + viewCount: 42, + }, + ], + }, + }, + { + name: 'Mahmoud', + email: 'mahmoud@prisma.io', + posts: { + create: [ + { + title: 'Ask a question about Prisma on GitHub', + content: 'https://www.github.com/prisma/prisma/discussions', + published: true, + viewCount: 128, + }, + { + title: 'Prisma on YouTube', + content: 'https://pris.ly/youtube', + }, + ], + }, + }, +] + +async function main() { + console.log(`Start seeding ...`) + for (const u of userData) { + const user = await prisma.user.create({ + data: u, + }) + console.log(`Created user with id: ${user.id}`) + } + console.log(`Seeding finished.`) +} + +main() + .then(async () => { + await prisma.$disconnect() + }) + .catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) + }) diff --git a/orm/graphql-gqloom/schema.graphql b/orm/graphql-gqloom/schema.graphql new file mode 100644 index 000000000000..811dae81a539 --- /dev/null +++ b/orm/graphql-gqloom/schema.graphql @@ -0,0 +1,279 @@ +input BoolFilter { + equals: Boolean + not: NestedBoolFilter +} + +""" +A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. +""" +scalar DateTime + +input DateTimeFilter { + equals: DateTime + gt: DateTime + gte: DateTime + in: [DateTime!] + lt: DateTime + lte: DateTime + not: NestedDateTimeFilter + notIn: [DateTime!] +} + +input FeedOrderByInput { + updatedAt: FeedOrderByUpdatedAtInput +} + +enum FeedOrderByUpdatedAtInput { + asc + desc +} + +input IntFilter { + equals: Int + gt: Int + gte: Int + in: [Int!] + lt: Int + lte: Int + not: NestedIntFilter + notIn: [Int!] +} + +input IntNullableFilter { + equals: Int + gt: Int + gte: Int + in: [Int!] + lt: Int + lte: Int + not: NestedIntNullableFilter + notIn: [Int!] +} + +type Mutation { + createDraft(authorEmail: String!, data: PostCreateInput!): Post! + deletePost(id: Int!): Post! + incrementPostViewCount(id: Int!): Post! + signupUser(data: SignupUserDataInput!): User! + togglePublishPost(id: Int!): Post! +} + +input NestedBoolFilter { + equals: Boolean + not: NestedBoolFilter +} + +input NestedDateTimeFilter { + equals: DateTime + gt: DateTime + gte: DateTime + in: [DateTime!] + lt: DateTime + lte: DateTime + not: NestedDateTimeFilter + notIn: [DateTime!] +} + +input NestedIntFilter { + equals: Int + gt: Int + gte: Int + in: [Int!] + lt: Int + lte: Int + not: NestedIntFilter + notIn: [Int!] +} + +input NestedIntNullableFilter { + equals: Int + gt: Int + gte: Int + in: [Int!] + lt: Int + lte: Int + not: NestedIntNullableFilter + notIn: [Int!] +} + +input NestedStringFilter { + contains: String + endsWith: String + equals: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + not: NestedStringFilter + notIn: [String!] + startsWith: String +} + +input NestedStringNullableFilter { + contains: String + endsWith: String + equals: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + not: NestedStringNullableFilter + notIn: [String!] + startsWith: String +} + +enum NullsOrder { + first + last +} + +type Post { + author: User + content: String + createdAt: DateTime! + id: ID! + published: Boolean! + title: String! + updatedAt: DateTime! + viewCount: Int! +} + +input PostCreateInput { + content: String + title: String! +} + +input PostListRelationFilter { + every: PostWhereInput + none: PostWhereInput + some: PostWhereInput +} + +input PostOrderByRelationAggregateInput { + _count: SortOrder +} + +input PostWhereInput { + AND: [PostWhereInput!] + NOT: [PostWhereInput!] + OR: [PostWhereInput!] + author: UserNullableScalarRelationFilter + authorId: IntNullableFilter + content: StringNullableFilter + createdAt: DateTimeFilter + id: IntFilter + published: BoolFilter + title: StringFilter + updatedAt: DateTimeFilter + viewCount: IntFilter +} + +type Query { + draftsByUser(userUniqueInput: UserUniqueInput!): [Post!]! + feed(orderBy: FeedOrderByInput, searchString: String, skip: Float, take: Float): [Post!]! + postById(id: Int!): Post + users(cursor: UserWhereUniqueInput, distinct: [UserScalarFieldEnum!], orderBy: [UserOrderByWithRelationInput!], skip: Int, take: Int, where: UserWhereInput): [User!]! +} + +enum QueryMode { + default + insensitive +} + +input SignupUserDataInput { + email: String! + name: String + posts: [PostCreateInput!]! +} + +enum SortOrder { + asc + desc +} + +input SortOrderInput { + nulls: NullsOrder + sort: SortOrder! +} + +input StringFilter { + contains: String + endsWith: String + equals: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + mode: QueryMode + not: NestedStringFilter + notIn: [String!] + startsWith: String +} + +input StringNullableFilter { + contains: String + endsWith: String + equals: String + gt: String + gte: String + in: [String!] + lt: String + lte: String + mode: QueryMode + not: NestedStringNullableFilter + notIn: [String!] + startsWith: String +} + +type User { + email: String! + id: ID! + name: String + posts: [Post!]! +} + +input UserNullableScalarRelationFilter { + is: UserWhereInput + isNot: UserWhereInput +} + +input UserOrderByWithRelationInput { + email: SortOrder + id: SortOrder + name: SortOrderInput + posts: PostOrderByRelationAggregateInput +} + +enum UserScalarFieldEnum { + email + id + name +} + +input UserUniqueInput { + email: String + id: Int +} + +input UserWhereInput { + AND: [UserWhereInput!] + NOT: [UserWhereInput!] + OR: [UserWhereInput!] + email: StringFilter + id: IntFilter + name: StringNullableFilter + posts: PostListRelationFilter +} + +input UserWhereUniqueInput { + AND: [UserWhereInput!] + NOT: [UserWhereInput!] + OR: [UserWhereInput!] + email: String + id: Int + name: StringNullableFilter + posts: PostListRelationFilter +} \ No newline at end of file diff --git a/orm/graphql-gqloom/src/db.ts b/orm/graphql-gqloom/src/db.ts new file mode 100644 index 000000000000..c9f23d5a7d89 --- /dev/null +++ b/orm/graphql-gqloom/src/db.ts @@ -0,0 +1,12 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { PrismaClient } from './generated/prisma/client' +import { withAccelerate } from '@prisma/extension-accelerate' + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set') +} + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL, +}) +export const prisma = new PrismaClient({ adapter }).$extends(withAccelerate()) diff --git a/orm/graphql-gqloom/src/resolvers/post.ts b/orm/graphql-gqloom/src/resolvers/post.ts new file mode 100644 index 000000000000..7d9c7e4ad6c9 --- /dev/null +++ b/orm/graphql-gqloom/src/resolvers/post.ts @@ -0,0 +1,165 @@ +import { field, mutation, query, resolver } from '@gqloom/core' +import * as z from 'zod' +import { Post } from '../generated/gqloom' +import { PrismaResolverFactory } from '@gqloom/prisma' +import { useSelectedFields } from '@gqloom/prisma/context' +import { prisma } from '../db' + +export const PostCreateInput = z.object({ + __typename: z.literal('PostCreateInput').nullish(), + title: z.string(), + content: z + .string() + .nullish() + .transform((x) => x ?? undefined), +}) + +const postFactory = new PrismaResolverFactory(Post, prisma) + +export const postResolver = resolver.of(Post, { + authorId: field.hidden, + author: postFactory.relationField('author'), + + postById: query(Post.nullable()) + .input({ + id: z.int(), + }) + .resolve(({ id }) => + prisma.post.findUnique({ + select: useSelectedFields(Post), + where: { id }, + }), + ), + + feed: query(Post.list()) + .input({ + searchString: z.string().nullish(), + skip: z + .number() + .int() + .nullish() + .transform((x) => x ?? undefined), + take: z + .number() + .int() + .nullish() + .transform((x) => x ?? undefined), + orderBy: z + .object({ + updatedAt: z + .enum(['asc', 'desc']) + .nullish() + .transform((x) => x ?? undefined), + }) + .nullish() + .transform((x) => x ?? undefined), + }) + .resolve(({ searchString, skip, take, orderBy }) => { + const or = searchString + ? { + OR: [ + { title: { contains: searchString } }, + { content: { contains: searchString } }, + ], + } + : {} + + return prisma.post.findMany({ + select: useSelectedFields(Post), + where: { ...or, published: true }, + take, + skip, + orderBy, + }) + }), + draftsByUser: query(Post.list()) + .input({ + userUniqueInput: z.object({ + __typename: z.literal('UserUniqueInput').nullish(), + id: z + .number() + .int() + .nullish() + .transform((x) => x ?? undefined), + email: z + .email() + .nullish() + .transform((x) => x ?? undefined), + }), + }) + .resolve(({ userUniqueInput }) => { + return prisma.post.findMany({ + select: useSelectedFields(Post), + where: { + published: false, + author: { + is: { + id: userUniqueInput.id, + email: userUniqueInput.email, + }, + }, + }, + }) + }), + createDraft: mutation(Post) + .input({ + data: PostCreateInput, + authorEmail: z.email(), + }) + .resolve(({ data, authorEmail }) => { + return prisma.post.create({ + select: useSelectedFields(Post), + data: { + title: data.title, + content: data.content, + published: false, + author: { + connect: { email: authorEmail }, + }, + }, + }) + }), + + togglePublishPost: mutation(Post) + .input({ + id: z.number().int(), + }) + .resolve(async ({ id }) => { + // TODO: Simplify once https://github.com/prisma/prisma/issues/16715 is fixed + const postPublished = await prisma.post.findUnique({ + where: { id }, + select: { published: true }, + }) + if (!postPublished) { + throw new Error('Post not found') + } + return prisma.post.update({ + select: useSelectedFields(Post), + where: { id }, + data: { published: !postPublished?.published }, + }) + }), + + incrementPostViewCount: mutation(Post) + .input({ + id: z.int(), + }) + .resolve(({ id }) => { + return prisma.post.update({ + select: useSelectedFields(Post), + where: { id }, + data: { viewCount: { increment: 1 } }, + }) + }), + + deletePost: mutation(Post) + .input({ + id: z.int(), + }) + .resolve(({ id }) => { + return prisma.post.delete({ + select: useSelectedFields(Post), + where: { id }, + }) + }), +}) diff --git a/orm/graphql-gqloom/src/resolvers/user.ts b/orm/graphql-gqloom/src/resolvers/user.ts new file mode 100644 index 000000000000..611816174abc --- /dev/null +++ b/orm/graphql-gqloom/src/resolvers/user.ts @@ -0,0 +1,36 @@ +import { mutation, resolver } from '@gqloom/core' +import { PrismaResolverFactory } from '@gqloom/prisma' +import { useSelectedFields } from '@gqloom/prisma/context' +import * as z from 'zod' +import { User } from '../generated/gqloom' +import { prisma } from '../db' +import { PostCreateInput } from './post' + +const userFactory = new PrismaResolverFactory(User, prisma) + +export const userResolver = resolver.of(User, { + users: userFactory.findManyQuery(), + + posts: userFactory.relationField('posts'), + + signupUser: mutation(User) + .input({ + data: z.object({ + email: z.email(), + name: z.string().optional(), + posts: z.array(PostCreateInput), + }), + }) + .resolve(({ data }) => { + return prisma.user.create({ + select: useSelectedFields(User), + data: { + email: data.email, + name: data.name, + posts: { + create: data.posts.map(({ __typename, ...post }) => post), + }, + }, + }) + }), +}) diff --git a/orm/graphql-gqloom/src/server.ts b/orm/graphql-gqloom/src/server.ts new file mode 100644 index 000000000000..993c341341bf --- /dev/null +++ b/orm/graphql-gqloom/src/server.ts @@ -0,0 +1,46 @@ +import * as fs from 'node:fs' +import { createServer } from 'node:http' +import path from 'node:path' +import { weave } from '@gqloom/core' +import { asyncContextProvider } from '@gqloom/core/context' +import { PrismaWeaver } from '@gqloom/prisma' +import { ZodWeaver } from '@gqloom/zod' +import { lexicographicSortSchema, printSchema } from 'graphql' +import { GraphQLDateTime } from 'graphql-scalars' +import { createYoga } from 'graphql-yoga' +import { postResolver } from './resolvers/post' +import { userResolver } from './resolvers/user' + +const schema = weave( + ZodWeaver, + PrismaWeaver.config({ + presetGraphQLType: (type) => { + switch (type) { + case 'DateTime': + return GraphQLDateTime + } + }, + }), + asyncContextProvider, + userResolver, + postResolver, +) +try { + fs.writeFileSync( + path.join(__dirname, '../schema.graphql'), + printSchema(lexicographicSortSchema(schema)), + ) + console.info('โœ… GraphQL schema written to schema.graphql') +} catch (error) { + console.error('โš ๏ธ Failed to write schema file:', error) + // Non-fatal: server can still run without the file +} + +const yoga = createYoga({ + graphqlEndpoint: '/', + schema, +}) +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000') +}) diff --git a/orm/graphql-gqloom/tsconfig.json b/orm/graphql-gqloom/tsconfig.json new file mode 100644 index 000000000000..de052a6133c0 --- /dev/null +++ b/orm/graphql-gqloom/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "prisma/**/*"] +}