iT邦幫忙

2023 iThome 鐵人賽

DAY 25
0
Modern Web

由前向後,從前端邁向全端系列 第 25

25.【從前端到全端,Nextjs+Nestjs】在Nestjs GraphQL加入Pagination

  • 分享至 

  • xImage
  •  

文章重點

  • 使用@devoxa/prisma-relay-cursor-connection套件來實現cursor based pagination
  • 修改GraphQL schema, resolver及service來實現基於游標的分頁功能

本文

本文我們將展示如何在Nest.js的GraphQL中實現cursor-based的分頁功能。我們首先安裝需要的套件:

pnpm add @devoxa/prisma-relay-cursor-connection

我們在common目錄下創建一個專門處理分頁功能的資料夾apps\iron-ecommerce-server\src\common\pagination
https://ithelp.ithome.com.tw/upload/images/20231010/20108931am0tcpvtu9.png
我們創建了 page-info.model.tspagination.ts,它們定義了分頁相關的data model

///// apps\iron-ecommerce-server\src\common\pagination\page-info.model.ts

import { Field, ObjectType } from "@nestjs/graphql";

@ObjectType()
export class PageInfo {
	@Field(() => String, { nullable: true })
	endCursor?: string;

	@Field(() => Boolean)
	hasNextPage: boolean;

	@Field(() => Boolean)
	hasPreviousPage: boolean;

	@Field(() => String, { nullable: true })
	startCursor?: string;
}


///// apps\iron-ecommerce-server\src\common\pagination\pagination.ts

import { PageInfo } from "./page-info.model";
import { Type } from "@nestjs/common";
import { Field, Int, ObjectType } from "@nestjs/graphql";

export default function Paginated<TItem>(TItemClass: Type<TItem>) {
	@ObjectType(`${TItemClass.name}Edge`)
	abstract class EdgeType {
		@Field(() => String)
		cursor: string;

		@Field(() => TItemClass)
		node: TItem;
	}

	@ObjectType({ isAbstract: true })
	abstract class PaginatedType {
		@Field(() => [EdgeType], { nullable: true })
		edges: Array<EdgeType>;

		@Field(() => [TItemClass], { nullable: true })
		nodes: Array<TItem>;

		@Field(() => PageInfo)
		pageInfo: PageInfo;

		@Field(() => Int)
		totalCount: number;
	}
	return PaginatedType;
}


並且我們創建一個args來使用

///// apps\iron-ecommerce-server\src\common\pagination\pagination.args.ts

import { ArgsType } from "@nestjs/graphql";

@ArgsType()
export class PaginationArgs {
	skip?: number;
	after?: string;
	before?: string;
	first?: number;
	last?: number;
}


接下來,我們打開Product Resolver,修改我們的query,改成使用cursor based pagination。並且我們先在model創建ProductConnection

///// apps\iron-ecommerce-server\src\api\products\products.model.ts

import PaginatedResponse from "../../common/pagination/pagination";
import { Field, InputType, Int, ObjectType } from "@nestjs/graphql";

@ObjectType()
export class Product {
	@Field()
	id: string;

	@Field()
	name: string;

	@Field()
	price: number;

	@Field()
	description: string;

	@Field()
	imageUrl: string;
}

@InputType()
export class NewProductInput {
	@Field()
	name: string;

	@Field(() => Int)
	price: number;

	@Field()
	description: string;

	@Field()
	imageUrl: string;
}

@InputType()
export class UpdateProductInput {
	@Field()
	id: string;

	@Field()
	name: string;

	@Field(() => Int)
	price: number;

	@Field()
	description: string;

	@Field()
	imageUrl: string;
}

@ObjectType()
export class ProductConnection extends PaginatedResponse(Product) {}

接著我們修正resolver和service。我們修正getProducts和findAll

///// apps\iron-ecommerce-server\src\api\products\products.resolver.ts

import { PaginationArgs } from "../../common/pagination/pagination.args";
import { NewProductInput, Product, ProductConnection, UpdateProductInput } from "./products.model";
import { ProductService } from "./products.service";
import { Args, Mutation, Query, Resolver } from "@nestjs/graphql";

@Resolver(() => Product)
export class ProductsResolver {
	constructor(private readonly productService: ProductService) {}

	@Query(() => ProductConnection)
	async getProducts(
		@Args() { after, before, first, last }: PaginationArgs,
		@Args({ name: "query", type: () => String, nullable: true }) query?: string
	): Promise<ProductConnection> {
		return await this.productService.findAll({ first, last, before, after }, query);
	}

	@Query(() => Product, { nullable: true })
	async getProduct(@Args("id") id: string): Promise<Product | null> {
		return await this.productService.findOne(id);
	}

    // ...others

///// apps\iron-ecommerce-server\src\api\products\products.service.ts

import { PaginationArgs } from "../../common/pagination/pagination.args";
import { Product, ProductConnection } from "./products.model";
import { findManyCursorConnection } from "@devoxa/prisma-relay-cursor-connection";
import { Injectable } from "@nestjs/common";

import { PrismaService } from "nestjs-prisma";

@Injectable()
export class ProductService {
	constructor(private readonly prisma: PrismaService) {}

	async findAll(paginationArgs: PaginationArgs, query?: string): Promise<ProductConnection> {
		return await findManyCursorConnection(
			(args) =>
				this.prisma.product.findMany({
					where: {
						name: { contains: query ?? "" }
					},
					...args
				}),
			() =>
				this.prisma.product.count({
					where: {
						name: { contains: query ?? "" }
					}
				}),
			paginationArgs
		);
	}

	async findOne(id: string): Promise<Product | null> {
		return this.prisma.product.findUnique({ where: { id } });
	}

    // ...others
}


並且修改一下我們的graphql schema:

scalar DateTime

input NewProductInput {
	name: String!
	price: Float!
	description: String!
	imageUrl: String!
}

input UpdateProductInput {
	id: ID!
	name: String!
	price: Float!
	description: String!
	imageUrl: String!
}

type Product {
	id: ID!
	name: String!
	price: Float!
	description: String!
	imageUrl: String!
}

type ProductEdge {
	cursor: String!
	node: Product!
}

type ProductConnection {
	edges: [ProductEdge!]!
	nodes: [Product!]!
	pageInfo: PageInfo!
	totalCount: Int!
}

type PageInfo {
	endCursor: String
	hasNextPage: Boolean!
	hasPreviousPage: Boolean!
	startCursor: String
}

type CartItem {
	productId: ID!
	productName: String!
	price: Float!
	quantity: Int!
}

type User {
	id: ID!
	name: String!
	email: String!
}

type AuthPayload {
	user: User!
	token: String!
}

input UserInput {
	name: String!
	email: String!
	password: String!
}

input CartItemInput {
	productId: ID!
	quantity: Int!
}

type Order {
	id: ID!
	items: [CartItem!]!
	orderDate: DateTime!
}

type Query {
	getProducts(first: Int, after: String, last: Int, before: String): ProductConnection!
	getProduct(id: ID!): Product
	getUserProfile: User
	getCartItems: [CartItem!]!
}

type Mutation {
	loginUser(username: String!, password: String!): AuthPayload
	registerUser(input: UserInput!): AuthPayload
	addProduct(input: NewProductInput!): Product
	updateProduct(input: UpdateProductInput!): Product
	deleteProduct(id: ID!): Boolean
	addCartItem(productId: ID!, quantity: Int!): [CartItem!]!
	removeCartItem(productId: ID!): [CartItem!]!
	updateCartItem(productId: ID!, quantity: Int!): [CartItem!]!
	checkout(cartItems: [CartItemInput!]!): Order
}

並執行pnpm exec nx run iron-ecommerce-server:gen-gql-type生成我們需要的type

接下來我們進行測試,執行

pnpm exec nx run iron-ecommerce-server:serve  

我們來執行query

{
  getProducts(first: 5) {
    edges {
      cursor
      node {
        id
        name
        description
        imageUrl
      }
    }
    pageInfo {
      endCursor
      hasNextPage
      hasPreviousPage
      startCursor
    }
    totalCount
  }
}
{
  "data": {
    "getProducts": {
      "edges": [
        {
          "cursor": "clnhcdmrc0000vobou8alt993",
          "node": {
            "id": "clnhcdmrc0000vobou8alt993",
            "name": "Tasty Cotton Shirt",
            "description": "Carbonite web goalkeeper gloves are ergonomically designed to give easy fit",
            "imageUrl": "https://picsum.photos/seed/QWiIkIDj/640/480"
          }
        },
        {
          "cursor": "clnhcdmrp0001vobo9h7mfsg2",
          "node": {
            "id": "clnhcdmrp0001vobo9h7mfsg2",
            "name": "Updated Product Name",
            "description": "Updated Product Description",
            "imageUrl": "http://example.com/updated-product.jpg"
          }
        },
        {
          "cursor": "clnhcdms00003vobowhfrdxcg",
          "node": {
            "id": "clnhcdms00003vobowhfrdxcg",
            "name": "Modern Plastic Hat",
            "description": "The Football Is Good For Training And Recreational Purposes",
            "imageUrl": "https://loremflickr.com/640/480?lock=3932651855544320"
          }
        },
        {
          "cursor": "clnhcdms20004vobo9ia3cx4j",
          "node": {
            "id": "clnhcdms20004vobo9ia3cx4j",
            "name": "Ergonomic Rubber Pants",
            "description": "The Apollotech B340 is an affordable wireless mouse with reliable connectivity, 12 months battery life and modern design",
            "imageUrl": "https://loremflickr.com/640/480?lock=988230264553472"
          }
        },
        {
          "cursor": "clnhcdms70005vobo78l2kx43",
          "node": {
            "id": "clnhcdms70005vobo78l2kx43",
            "name": "Bespoke Bronze Car",
            "description": "Ergonomic executive chair upholstered in bonded black leather and PVC padded seat and back for all-day comfort and support",
            "imageUrl": "https://picsum.photos/seed/SpRUrRv/640/480"
          }
        }
      ],
      "pageInfo": {
        "endCursor": "clnhcdms70005vobo78l2kx43",
        "hasNextPage": true,
        "hasPreviousPage": false,
        "startCursor": "clnhcdmrc0000vobou8alt993"
      },
      "totalCount": 50
    }
  }
}

總結

本文介紹了如何在Nest.js的GraphQL中實現了cursor-based的分頁功能


上一篇
24.【從前端到全端,Nextjs+Nestjs】使用Prisma service修改Service並操作
下一篇
26.【從前端到全端,Nextjs+Nestjs】加入authenication(一)
系列文
由前向後,從前端邁向全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言