@devoxa/prisma-relay-cursor-connection
套件來實現cursor based pagination本文我們將展示如何在Nest.js的GraphQL中實現cursor-based的分頁功能。我們首先安裝需要的套件:
pnpm add @devoxa/prisma-relay-cursor-connection
我們在common目錄下創建一個專門處理分頁功能的資料夾apps\iron-ecommerce-server\src\common\pagination
我們創建了 page-info.model.ts
和pagination.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的分頁功能