diff --git a/package.json b/package.json index 206c9bc7f..7359c1225 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.19.1", + "version": "2.19.2", "description": "", "scripts": { "build": "pnpm -r --filter=\"!./packages/ide/*\" build", diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 5ff8b2676..ad7a48099 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.19.1" +version = "2.19.2" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index c599f8ae2..43f668c48 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.19.1", + "version": "2.19.2", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index ef285d5d5..69e84e7b3 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.19.1", + "version": "2.19.2", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index 777badd89..a2234e33c 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "2.19.1", + "version": "2.19.2", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index e712470da..8e7fdfe2f 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index f119efd6b..e8b12c178 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index d35f71f78..922f23b46 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 3cf86bd82..c05988444 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f8673286b..162ea9d67 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.19.1", + "version": "2.19.2", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/cross/model-meta.ts b/packages/runtime/src/cross/model-meta.ts index e125eec8b..805b3d6d1 100644 --- a/packages/runtime/src/cross/model-meta.ts +++ b/packages/runtime/src/cross/model-meta.ts @@ -12,7 +12,7 @@ export type RuntimeAttribute = { /** * Attribute arguments */ - args: Array<{ name?: string; value: unknown }>; + args: Array<{ name?: string; value?: unknown }>; }; /** diff --git a/packages/runtime/src/enhancements/node/delegate.ts b/packages/runtime/src/enhancements/node/delegate.ts index 56f918f40..977e1c199 100644 --- a/packages/runtime/src/enhancements/node/delegate.ts +++ b/packages/runtime/src/enhancements/node/delegate.ts @@ -228,6 +228,9 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { data[field] = {}; } await this.injectSelectIncludeHierarchy(fieldInfo.type, data[field]); + if (data[field].where) { + this.injectWhereHierarchy(fieldInfo.type, data[field].where); + } } } diff --git a/packages/schema/build/bundle.js b/packages/schema/build/bundle.js index e851b7ce9..1cd8d47da 100644 --- a/packages/schema/build/bundle.js +++ b/packages/schema/build/bundle.js @@ -2,7 +2,8 @@ const watch = process.argv.includes('--watch'); const minify = process.argv.includes('--minify'); const success = watch ? 'Watch build succeeded' : 'Build succeeded'; const fs = require('fs'); -const path = require('path'); +require('dotenv').config({ path: './.env.local' }); +require('dotenv').config({ path: './.env' }); // Replace telemetry token in generated bundle files after building function replaceTelemetryTokenInBundle() { diff --git a/packages/schema/package.json b/packages/schema/package.json index 24c8a3ef7..e447d0943 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.19.1", + "version": "2.19.2", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index daebb2955..89ff25199 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index 84ea5ff2e..26e518782 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -80,13 +80,20 @@ export function generate( ) { const sf = project.createSourceFile(options.output, undefined, { overwrite: true }); + // generate: import type { ModelMeta } from '@zenstackhq/runtime'; + sf.addImportDeclaration({ + isTypeOnly: true, + namedImports: ['ModelMeta'], + moduleSpecifier: '@zenstackhq/runtime', + }); + const writer = new FastWriter(); const extraFunctions: OptionalKind[] = []; generateModelMetadata(models, typeDefs, writer, options, extraFunctions); sf.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, - declarations: [{ name: 'metadata', initializer: writer.result }], + declarations: [{ name: 'metadata', type: 'ModelMeta', initializer: writer.result }], }); if (extraFunctions.length > 0) { @@ -364,7 +371,7 @@ function writeFields( function getAttributes(target: DataModelField | DataModel | TypeDefField): RuntimeAttribute[] { return target.attributes .map((attr) => { - const args: Array<{ name?: string; value: unknown }> = []; + const args: Array<{ name?: string; value?: unknown }> = []; for (const arg of attr.args) { const argName = arg.$resolvedParam?.name ?? arg.name; const argValue = exprToValue(arg.value); diff --git a/packages/server/package.json b/packages/server/package.json index 3cc612d6b..3db74cc38 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.19.1", + "version": "2.19.2", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 7d9f220ac..3e13bbc85 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -177,6 +177,10 @@ class RequestHandler extends APIHandlerBase { status: 400, title: 'Invalid value for type', }, + duplicatedFieldsParameter: { + status: 400, + title: 'Fields Parameter Duplicated', + }, forbidden: { status: 403, title: 'Operation is forbidden', @@ -185,6 +189,7 @@ class RequestHandler extends APIHandlerBase { status: 422, title: 'Operation is unprocessable due to validation errors', }, + unknownError: { status: 400, title: 'Unknown error', @@ -511,7 +516,7 @@ class RequestHandler extends APIHandlerBase { // handle "include" query parameter let include: string[] | undefined; if (query?.include) { - const { select, error, allIncludes } = this.buildRelationSelect(type, query.include); + const { select, error, allIncludes } = this.buildRelationSelect(type, query.include, query); if (error) { return error; } @@ -521,6 +526,20 @@ class RequestHandler extends APIHandlerBase { include = allIncludes; } + // handle partial results for requested type + const { select, error } = this.buildPartialSelect(type, query); + if (error) return error; + if (select) { + args.select = { ...select, ...args.select }; + if (args.include) { + args.select = { + ...args.select, + ...args.include, + }; + args.include = undefined; + } + } + const entity = await prisma[type].findUnique(args); if (entity) { @@ -555,7 +574,7 @@ class RequestHandler extends APIHandlerBase { // handle "include" query parameter let include: string[] | undefined; if (query?.include) { - const { select: relationSelect, error, allIncludes } = this.buildRelationSelect(type, query.include); + const { select: relationSelect, error, allIncludes } = this.buildRelationSelect(type, query.include, query); if (error) { return error; } @@ -566,7 +585,14 @@ class RequestHandler extends APIHandlerBase { select = relationSelect; } - select = select ?? { [relationship]: true }; + // handle partial results for requested type + if (!select) { + const { select: partialFields, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query); + if (error) return error; + + select = partialFields ? { [relationship]: { select: { ...partialFields } } } : { [relationship]: true }; + } + const args: any = { where: this.makePrismaIdFilter(typeInfo.idFields, resourceId), select, @@ -710,7 +736,7 @@ class RequestHandler extends APIHandlerBase { // handle "include" query parameter let include: string[] | undefined; if (query?.include) { - const { select, error, allIncludes } = this.buildRelationSelect(type, query.include); + const { select, error, allIncludes } = this.buildRelationSelect(type, query.include, query); if (error) { return error; } @@ -720,6 +746,20 @@ class RequestHandler extends APIHandlerBase { include = allIncludes; } + // handle partial results for requested type + const { select, error } = this.buildPartialSelect(type, query); + if (error) return error; + if (select) { + args.select = { ...select, ...args.select }; + if (args.include) { + args.select = { + ...args.select, + ...args.include, + }; + args.include = undefined; + } + } + const { offset, limit } = this.getPagination(query); if (offset > 0) { args.skip = offset; @@ -738,6 +778,7 @@ class RequestHandler extends APIHandlerBase { }; } else { args.take = limit; + const [entities, count] = await Promise.all([ prisma[type].findMany(args), prisma[type].count({ where: args.where ?? {} }), @@ -762,6 +803,33 @@ class RequestHandler extends APIHandlerBase { } } + private buildPartialSelect(type: string, query: Record | undefined) { + const selectFieldsQuery = query?.[`fields[${type}]`]; + if (!selectFieldsQuery) { + return { select: undefined, error: undefined }; + } + + if (Array.isArray(selectFieldsQuery)) { + return { + select: undefined, + error: this.makeError('duplicatedFieldsParameter', `duplicated fields query for type ${type}`), + }; + } + + const typeInfo = this.typeMap[lowerCaseFirst(type)]; + if (!typeInfo) { + return { select: undefined, error: this.makeUnsupportedModelError(type) }; + } + + const selectFieldNames = selectFieldsQuery.split(',').filter((i) => i); + + const fields = selectFieldNames.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}); + + return { + select: { ...this.makeIdSelect(typeInfo.idFields), ...fields }, + }; + } + private addTotalCountToMeta(meta: any, total: any) { return meta ? Object.assign(meta, { total }) : Object.assign({}, { total }); } @@ -1790,7 +1858,11 @@ class RequestHandler extends APIHandlerBase { return { sort: result, error: undefined }; } - private buildRelationSelect(type: string, include: string | string[]) { + private buildRelationSelect( + type: string, + include: string | string[], + query: Record | undefined + ) { const typeInfo = this.typeMap[lowerCaseFirst(type)]; if (!typeInfo) { return { select: undefined, error: this.makeUnsupportedModelError(type) }; @@ -1820,11 +1892,24 @@ class RequestHandler extends APIHandlerBase { return { select: undefined, error: this.makeUnsupportedModelError(relationInfo.type) }; } + // handle partial results for requested type + const { select, error } = this.buildPartialSelect(lowerCaseFirst(relationInfo.type), query); + if (error) return { select: undefined, error }; + if (i !== parts.length - 1) { - currPayload[relation] = { include: { ...currPayload[relation]?.include } }; - currPayload = currPayload[relation].include; + if (select) { + currPayload[relation] = { select: { ...select } }; + currPayload = currPayload[relation].select; + } else { + currPayload[relation] = { include: { ...currPayload[relation]?.include } }; + currPayload = currPayload[relation].include; + } } else { - currPayload[relation] = true; + currPayload[relation] = select + ? { + select: { ...select }, + } + : true; } } } diff --git a/packages/server/tests/api/rest-partial.test.ts b/packages/server/tests/api/rest-partial.test.ts new file mode 100644 index 000000000..ac4a28f50 --- /dev/null +++ b/packages/server/tests/api/rest-partial.test.ts @@ -0,0 +1,473 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/// + +import { type ModelMeta } from '@zenstackhq/runtime'; +import { loadSchema, run } from '@zenstackhq/testtools'; +import makeHandler from '../../src/api/rest'; + +describe('REST server tests', () => { + let prisma: any; + let zodSchemas: any; + let modelMeta: ModelMeta; + let handler: (any: any) => Promise<{ status: number; body: any }>; + + beforeEach(async () => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + }); + + describe('REST server tests - sparse fieldsets', () => { + const schema = ` + model User { + myId String @id @default(cuid()) + createdAt DateTime @default (now()) + updatedAt DateTime @updatedAt + email String @unique @email + nickName String + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default (now()) + updatedAt DateTime @updatedAt + title String @length(1, 10) + content String + author User? @relation(fields: [authorId], references: [myId]) + authorId String? + published Boolean @default(false) + publishedAt DateTime? + viewCount Int @default(0) + comments Comment[] + } + + model Comment { + id Int @id @default(autoincrement()) + post Post @relation(fields: [postId], references: [id]) + postId Int + content String + } + `; + + beforeAll(async () => { + const params = await loadSchema(schema); + + prisma = params.prisma; + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + it('returns only the requested fields when there are some in the database', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { title: 'Post1', content: 'Post 1 Content' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { title: 'Post2', content: 'Post 2 Content' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { ['fields[user]']: 'email,nickName' }, + }); + + expect(r.status).toBe(200); + + expect(r.body.data[0].attributes).toEqual({ + email: 'user1@abc.com', + nickName: 'one', + }); + + expect(r.body.data[1].attributes).toEqual({ + email: 'user2@abc.com', + nickName: 'two', + }); + }); + + it('returns collection with only the requested fields when there are includes', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { title: 'Post1', content: 'Post 1 Content' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { title: 'Post2', content: 'Post 2 Content', published: true }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { ['fields[user]']: 'email,nickName', ['fields[post]']: 'title,published', include: 'posts' }, + }); + + expect(r.status).toBe(200); + + expect(r.body.data[0].attributes).toEqual({ + email: 'user1@abc.com', + nickName: 'one', + }); + + expect(r.body.data[1].attributes).toEqual({ + email: 'user2@abc.com', + nickName: 'two', + }); + + expect(r.body.included[0].attributes).toEqual({ + title: 'Post1', + published: false, + }); + + expect(r.body.included[1].attributes).toEqual({ + title: 'Post2', + published: true, + }); + }); + + it('returns collection with only the requested fields when there are deep includes', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { + title: 'Post1', + content: 'Post 1 Content', + comments: { create: { content: 'Comment1' } }, + }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { + title: 'Post2', + content: 'Post 2 Content', + published: true, + comments: { create: { content: 'Comment2' } }, + }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { + ['fields[user]']: 'email,nickName', + ['fields[post]']: 'title,published', + ['fields[comment]']: 'content', + include: 'posts,posts.comments', + }, + }); + + expect(r.status).toBe(200); + + expect(r.body.data[0].attributes).toEqual({ + email: 'user1@abc.com', + nickName: 'one', + }); + + expect(r.body.data[1].attributes).toEqual({ + email: 'user2@abc.com', + nickName: 'two', + }); + + expect(r.body.included[0].attributes).toEqual({ + title: 'Post1', + published: false, + }); + + expect(r.body.included[1].attributes).toEqual({ + title: 'Post2', + published: true, + }); + + expect(r.body.included[2].attributes).toEqual({ content: 'Comment1' }); + expect(r.body.included[3].attributes).toEqual({ content: 'Comment2' }); + }); + + it('returns collection with only the requested fields when there are sparse fields on deep includes', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { + title: 'Post1', + content: 'Post 1 Content', + comments: { create: { content: 'Comment1' } }, + }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { + title: 'Post2', + content: 'Post 2 Content', + published: true, + comments: { create: { content: 'Comment2' } }, + }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { + ['fields[user]']: 'email,nickName', + ['fields[comment]']: 'content', + include: 'posts,posts.comments', + }, + }); + + expect(r.status).toBe(200); + + expect(r.body.data[0].attributes).toEqual({ + email: 'user1@abc.com', + nickName: 'one', + }); + + expect(r.body.data[1].attributes).toEqual({ + email: 'user2@abc.com', + nickName: 'two', + }); + + //did not use sparse field on posts, only comments + expect(r.body.included[0].attributes).toMatchObject({ + title: 'Post1', + published: false, + }); + + //did not use sparse field on posts, only comments + expect(r.body.included[1].attributes).toMatchObject({ + title: 'Post2', + published: true, + }); + + expect(r.body.included[2].attributes).toEqual({ content: 'Comment1' }); + expect(r.body.included[3].attributes).toEqual({ content: 'Comment2' }); + }); + + it('returns only the requested fields when the ID is specified', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'User 1', + posts: { create: { title: 'Post1', content: 'Post 1 Content' } }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + query: { ['fields[user]']: 'email' }, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes).toEqual({ email: 'user1@abc.com' }); + }); + + it('returns only the requested fields when the ID is specified and has an include', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'User 1', + posts: { create: { title: 'Post1', content: 'Post 1 Content' } }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + query: { ['fields[user]']: 'email,nickName', ['fields[post]']: 'title,published', include: 'posts' }, + }); + + expect(r.status).toBe(200); + expect(r.body.data.attributes).toEqual({ email: 'user1@abc.com', nickName: 'User 1' }); + + expect(r.body.included[0].attributes).toEqual({ + title: 'Post1', + published: false, + }); + }); + + it('fetch only requested fields on a related resource', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { title: 'Post1', content: 'Post 1 Content' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + query: { ['fields[post]']: 'title,content' }, + }); + + expect(r.status).toBe(200); + expect(r.body.data[0].attributes).toEqual({ + title: 'Post1', + content: 'Post 1 Content', + }); + }); + + it('does not efect toplevel filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { id: 1, title: 'Post1', content: 'Post 1 Content' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { id: 2, title: 'Post2', content: 'Post 2 Content', viewCount: 1, published: true }, + }, + }, + }); + + // id filter + const r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user2', ['fields[user]']: 'email' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + expect(r.body.data[0].attributes).not.toMatchObject({ nickName: 'two' }); + }); + + it('does not efect toplevel sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + nickName: 'one', + posts: { + create: { id: 1, title: 'Post1', content: 'Post 1 Content', viewCount: 1, published: true }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { id: 2, title: 'Post2', content: 'Post 2 Content', viewCount: 2, published: false }, + }, + }, + }); + + // basic sorting + const r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount', ['fields[post]']: 'title' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + }); + + it('does not efect toplevel pagination', async () => { + for (const i of Array(5).keys()) { + await prisma.user.create({ + data: { + myId: `user${i}`, + email: `user${i}@abc.com`, + nickName: `{i}`, + }, + }); + } + + // limit only + const r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3', ['fields[user]']: 'email' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?fields%5Buser%5D=email&page%5Blimit%5D=3', + last: 'http://localhost/api/user?fields%5Buser%5D=email&page%5Boffset%5D=3', + prev: null, + next: 'http://localhost/api/user?fields%5Buser%5D=email&page%5Boffset%5D=3&page%5Blimit%5D=3', + }); + }); + }); +}); diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 58b7d89e7..b37be91c2 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.19.1", + "version": "2.19.2", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/tests/regression/tests/issue-2246.test.ts b/tests/regression/tests/issue-2246.test.ts new file mode 100644 index 000000000..ab487e6d9 --- /dev/null +++ b/tests/regression/tests/issue-2246.test.ts @@ -0,0 +1,82 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 2246', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` +model Media { + id Int @id @default(autoincrement()) + title String + mediaType String + + @@delegate(mediaType) + @@allow('all', true) +} + +model Movie extends Media { + director Director @relation(fields: [directorId], references: [id]) + directorId Int + duration Int + rating String +} + +model Director { + id Int @id @default(autoincrement()) + name String + email String + movies Movie[] + + @@allow('all', true) +} + ` + ); + + const db = enhance(); + + await db.director.create({ + data: { + name: 'Christopher Nolan', + email: 'christopher.nolan@example.com', + movies: { + create: { + title: 'Inception', + duration: 148, + rating: 'PG-13', + }, + }, + }, + }); + + await expect( + db.director.findMany({ + include: { + movies: { + where: { title: 'Inception' }, + }, + }, + }) + ).resolves.toHaveLength(1); + + await expect( + db.director.findFirst({ + include: { + _count: { select: { movies: { where: { title: 'Inception' } } } }, + }, + }) + ).resolves.toMatchObject({ _count: { movies: 1 } }); + + await expect( + db.movie.findMany({ + where: { title: 'Interstellar' }, + }) + ).resolves.toHaveLength(0); + + await expect( + db.director.findFirst({ + include: { + _count: { select: { movies: { where: { title: 'Interstellar' } } } }, + }, + }) + ).resolves.toMatchObject({ _count: { movies: 0 } }); + }); +});