From 3f8444bc201bb3c5c63e3fc01c405505ef06a3ce Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Mon, 22 Sep 2025 20:35:20 -0400 Subject: [PATCH 1/6] setup quick testing and debugging --- .vscode/launch.json | 8 + .../server/tests/api/rest-partial.test.ts | 932 ++++++++++++++++++ 2 files changed, 940 insertions(+) create mode 100644 packages/server/tests/api/rest-partial.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 18b3b5721..f17b3c1cc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,6 +5,14 @@ { "version": "0.2.0", "configurations": [ + { + "command": "pnpm jest -- rest-partial.test.ts", + "name": "Run npm start", + "request": "launch", + "type": "node-terminal", + "cwd": "${workspaceFolder}/packages/server" + }, + { "name": "Attach", "port": 9229, 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..80a8b60cf --- /dev/null +++ b/packages/server/tests/api/rest-partial.test.ts @@ -0,0 +1,932 @@ +/* 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) + author User? @relation(fields: [authorId], references: [myId]) + authorId String? + published Boolean @default(false) + publishedAt DateTime? + viewCount Int @default(0) + } + `; + + 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 all items 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' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { title: 'Post2' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user', + }, + meta: { + total: 2, + }, + data: [ + { + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com', nickName: 'one' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'post', id: 1 }], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { email: 'user2@abc.com', nickName: 'two' }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [{ type: 'post', id: 2 }], + }, + }, + }, + ], + }); + }); + + /** + it('returns a single item when the ID is specified', async () => { + // Create a user first + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'post', id: 1 }], + }, + }, + }, + }); + }); + + + it('fetch a related resource', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, + }, + }, + ], + }); + }); + + it('toplevel filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + address: { city: 'Seattle' }, + someJson: 'foo', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); + + // id filter + let r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user2' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + // multi-id filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1,user2' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(2); + + // String filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@bc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'ser1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // Int filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gte]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lte]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // Boolean filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // deep to-one filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author][email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // deep to-many filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts][published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // filter to empty + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user3' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-many relation collection filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '1,2,3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + + // multi filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + r = await handler({ + method: 'get', + path: '/post', + query: { + ['filter[author][email]']: 'user1@abc.com', + ['filter[title]']: 'Post1', + }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + r = await handler({ + method: 'get', + path: '/post', + query: { + ['filter[author][email]']: 'user1@abc.com', + ['filter[title]']: 'Post2', + }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-one relation filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // relation filter with multiple values + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1,user2' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + + // invalid filter field + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); + + // invalid filter value + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: 'a' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-value', + title: 'Invalid value for type', + }, + ], + }); + + // invalid filter operation + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); + + // typedef equality filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[address]']: JSON.stringify({ city: 'Seattle' }) }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[address]']: JSON.stringify({ city: 'Tokyo' }) }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // plain json equality filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[someJson]']: JSON.stringify('foo') }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[someJson]']: JSON.stringify('bar') }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // invalid json + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[someJson]']: '{ hello: world }' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-value', + title: 'Invalid value for type', + }, + ], + }); + }); + + it('toplevel sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', viewCount: 1, published: true }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 2, published: false }, + }, + }, + }); + + // basic sorting + let r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // basic sorting desc + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation id + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author.email' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // multi-field sorting + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'published,viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount,published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount,-published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // invalid field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + + // sort with collection + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'comments' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + + // sort with regular field in the middle + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount.foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); + }); + + it('including', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, + }, + profile: { + create: { gender: 'male' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { + id: 2, + title: 'Post2', + viewCount: 1, + published: true, + comments: { create: { content: 'Comment2' } }, + }, + }, + }, + }); + + // collection query include + let r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(2); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); + + // single query include + r = await handler({ + method: 'get', + path: '/user/user1', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); + + // related query include + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); + + // related query include with filter + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments', ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // deep include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(4); + expect(r.body.included[2]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); + + // multiple include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments,profile' }, + prisma, + }); + expect(r.body.included).toHaveLength(5); + const profile = r.body.included.find((item: any) => item.type === 'profile'); + expect(profile).toMatchObject({ + type: 'profile', + attributes: { gender: 'male' }, + }); + + // invalid include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [{ status: 400, code: 'unsupported-relationship' }], + }); + }); + + it('toplevel pagination', async () => { + for (const i of Array(5).keys()) { + await prisma.user.create({ + data: { + myId: `user${i}`, + email: `user${i}@abc.com`, + }, + }); + } + + // limit only + let r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: null, + next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); + + // limit & offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3', ['page[offset]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', + next: null, + }); + + // limit trimmed + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // offset overflow + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // minus offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '-1' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + + // zero limit + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); + }); + **/ + }); +}); From 08f7b31d087afe5f5d1f3a934eeb133d17f217c4 Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Thu, 25 Sep 2025 09:19:51 -0400 Subject: [PATCH 2/6] implement sparse fields for collection reads --- packages/server/src/api/rest/index.ts | 73 +++++- .../server/tests/api/rest-partial.test.ts | 219 +++++++++++++++++- 2 files changed, 286 insertions(+), 6 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 7d9f220ac..120206e68 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; } @@ -555,7 +560,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; } @@ -710,7 +715,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 +725,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 +757,9 @@ class RequestHandler extends APIHandlerBase { }; } else { args.take = limit; + + console.log('args', JSON.stringify(args)); + const [entities, count] = await Promise.all([ prisma[type].findMany(args), prisma[type].count({ where: args.where ?? {} }), @@ -762,6 +784,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 +1839,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,16 +1873,26 @@ 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; } else { - currPayload[relation] = true; + currPayload[relation] = select + ? { + select: { ...select }, + } + : true; } } } } + console.log('relation select:', JSON.stringify({ select: result, error: undefined, allIncludes })); + return { select: result, error: undefined, allIncludes }; } diff --git a/packages/server/tests/api/rest-partial.test.ts b/packages/server/tests/api/rest-partial.test.ts index 80a8b60cf..e0ed7a376 100644 --- a/packages/server/tests/api/rest-partial.test.ts +++ b/packages/server/tests/api/rest-partial.test.ts @@ -52,7 +52,7 @@ describe('REST server tests', () => { _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - it('returns all items when there are some in the database', async () => { + it('returns all items and fields when there are some in the database', async () => { // Create users first await prisma.user.create({ data: { @@ -128,6 +128,223 @@ describe('REST server tests', () => { }); }); + 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' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { title: 'Post2' }, + }, + }, + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { ['fields[user]']: 'email,nickName' }, //'fields[user]=email,nickName', + }); + + expect(r.status).toBe(200); + + console.log('body', JSON.stringify(r.body)); + + expect(r.body.data).toEqual([ + { + type: 'user', + id: 'user1', + attributes: { + email: 'user1@abc.com', + nickName: 'one', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'post', + id: 1, + }, + ], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { + email: 'user2@abc.com', + nickName: 'two', + }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [ + { + type: 'post', + id: 2, + }, + ], + }, + }, + }, + ]); + }); + + it('returns 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' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + nickName: 'two', + posts: { + create: { title: 'Post2', 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); + + console.log('body', JSON.stringify(r.body)); + + expect(r.body.data).toEqual([ + { + type: 'user', + id: 'user1', + attributes: { + email: 'user1@abc.com', + nickName: 'one', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'post', + id: 1, + }, + ], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { + email: 'user2@abc.com', + nickName: 'two', + }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [ + { + type: 'post', + id: 2, + }, + ], + }, + }, + }, + ]); + + expect(r.body.included).toEqual([ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + published: false, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, + }, + }, + { + type: 'post', + id: 2, + attributes: { + title: 'Post2', + published: true, + }, + links: { + self: 'http://localhost/api/post/2', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/2/relationships/author', + related: 'http://localhost/api/post/2/author', + }, + }, + }, + }, + ]); + }); + /** it('returns a single item when the ID is specified', async () => { // Create a user first From 7250f8fa2662c34a3508099d072554d8b1b01532 Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Thu, 25 Sep 2025 12:04:16 -0400 Subject: [PATCH 3/6] implement sparse fields for single reads --- packages/server/src/api/rest/index.ts | 14 + .../server/tests/api/rest-partial.test.ts | 598 ++++++++++-------- 2 files changed, 336 insertions(+), 276 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 120206e68..eb8cab8d8 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -526,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) { diff --git a/packages/server/tests/api/rest-partial.test.ts b/packages/server/tests/api/rest-partial.test.ts index e0ed7a376..2280add5d 100644 --- a/packages/server/tests/api/rest-partial.test.ts +++ b/packages/server/tests/api/rest-partial.test.ts @@ -32,6 +32,7 @@ describe('REST server tests', () => { 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) @@ -52,124 +53,322 @@ describe('REST server tests', () => { _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - it('returns all items and fields when there are some in the database', async () => { - // Create users first + // it('returns all items and 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, + // }); + + // expect(r.status).toBe(200); + // expect(r.body).toMatchObject({ + // links: { + // self: 'http://localhost/api/user', + // }, + // meta: { + // total: 2, + // }, + // data: [ + // { + // type: 'user', + // id: 'user1', + // attributes: { email: 'user1@abc.com', nickName: 'one' }, + // links: { + // self: 'http://localhost/api/user/user1', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user1/relationships/posts', + // related: 'http://localhost/api/user/user1/posts', + // }, + // data: [{ type: 'post', id: 1 }], + // }, + // }, + // }, + // { + // type: 'user', + // id: 'user2', + // attributes: { email: 'user2@abc.com', nickName: 'two' }, + // links: { + // self: 'http://localhost/api/user/user2', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user2/relationships/posts', + // related: 'http://localhost/api/user/user2/posts', + // }, + // data: [{ type: 'post', id: 2 }], + // }, + // }, + // }, + // ], + // }); + // }); + + // 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); + + // console.log('body', JSON.stringify(r.body)); + + // expect(r.body.data).toEqual([ + // { + // type: 'user', + // id: 'user1', + // attributes: { + // email: 'user1@abc.com', + // nickName: 'one', + // }, + // links: { + // self: 'http://localhost/api/user/user1', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user1/relationships/posts', + // related: 'http://localhost/api/user/user1/posts', + // }, + // data: [ + // { + // type: 'post', + // id: 1, + // }, + // ], + // }, + // }, + // }, + // { + // type: 'user', + // id: 'user2', + // attributes: { + // email: 'user2@abc.com', + // nickName: 'two', + // }, + // links: { + // self: 'http://localhost/api/user/user2', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user2/relationships/posts', + // related: 'http://localhost/api/user/user2/posts', + // }, + // data: [ + // { + // type: 'post', + // id: 2, + // }, + // ], + // }, + // }, + // }, + // ]); + // }); + + // 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', published: true }, + // }, + // }, + // }); + // 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); + + // console.log('body', JSON.stringify(r.body)); + + // expect(r.body.data).toEqual([ + // { + // type: 'user', + // id: 'user1', + // attributes: { + // email: 'user1@abc.com', + // nickName: 'one', + // }, + // links: { + // self: 'http://localhost/api/user/user1', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user1/relationships/posts', + // related: 'http://localhost/api/user/user1/posts', + // }, + // data: [ + // { + // type: 'post', + // id: 1, + // }, + // ], + // }, + // }, + // }, + // { + // type: 'user', + // id: 'user2', + // attributes: { + // email: 'user2@abc.com', + // nickName: 'two', + // }, + // links: { + // self: 'http://localhost/api/user/user2', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user2/relationships/posts', + // related: 'http://localhost/api/user/user2/posts', + // }, + // data: [ + // { + // type: 'post', + // id: 2, + // }, + // ], + // }, + // }, + // }, + // ]); + + // expect(r.body.included).toEqual([ + // { + // type: 'post', + // id: 1, + // attributes: { + // title: 'Post1', + // published: false, + // }, + // links: { + // self: 'http://localhost/api/post/1', + // }, + // relationships: { + // author: { + // links: { + // self: 'http://localhost/api/post/1/relationships/author', + // related: 'http://localhost/api/post/1/author', + // }, + // }, + // }, + // }, + // { + // type: 'post', + // id: 2, + // attributes: { + // title: 'Post2', + // published: true, + // }, + // links: { + // self: 'http://localhost/api/post/2', + // }, + // relationships: { + // author: { + // links: { + // self: 'http://localhost/api/post/2/relationships/author', + // related: 'http://localhost/api/post/2/author', + // }, + // }, + // }, + // }, + // ]); + // }); + + it('returns the full item when the ID is specified', async () => { + // Create a user first await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com', - nickName: 'one', - posts: { - create: { title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - nickName: 'two', - posts: { - create: { title: 'Post2' }, - }, + nickName: 'User 1', + posts: { create: { title: 'Post1', content: 'Post 1 Content' } }, }, }); const r = await handler({ method: 'get', - path: '/user', + path: '/user/user1', prisma, }); expect(r.status).toBe(200); expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user', - }, - meta: { - total: 2, - }, - data: [ - { - type: 'user', - id: 'user1', - attributes: { email: 'user1@abc.com', nickName: 'one' }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [{ type: 'post', id: 1 }], - }, - }, - }, - { - type: 'user', - id: 'user2', - attributes: { email: 'user2@abc.com', nickName: 'two' }, - links: { - self: 'http://localhost/api/user/user2', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user2/relationships/posts', - related: 'http://localhost/api/user/user2/posts', - }, - data: [{ type: 'post', id: 2 }], - }, - }, - }, - ], - }); - }); - - 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' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - nickName: 'two', - posts: { - create: { title: 'Post2' }, - }, - }, - }); - - const r = await handler({ - method: 'get', - path: '/user', - prisma, - query: { ['fields[user]']: 'email,nickName' }, //'fields[user]=email,nickName', - }); - - expect(r.status).toBe(200); - - console.log('body', JSON.stringify(r.body)); - - expect(r.body.data).toEqual([ - { type: 'user', id: 'user1', - attributes: { - email: 'user1@abc.com', - nickName: 'one', - }, + attributes: { email: 'user1@abc.com', nickName: 'User 1' }, links: { self: 'http://localhost/api/user/user1', }, @@ -179,206 +378,53 @@ describe('REST server tests', () => { self: 'http://localhost/api/user/user1/relationships/posts', related: 'http://localhost/api/user/user1/posts', }, - data: [ - { - type: 'post', - id: 1, - }, - ], - }, - }, - }, - { - type: 'user', - id: 'user2', - attributes: { - email: 'user2@abc.com', - nickName: 'two', - }, - links: { - self: 'http://localhost/api/user/user2', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user2/relationships/posts', - related: 'http://localhost/api/user/user2/posts', - }, - data: [ - { - type: 'post', - id: 2, - }, - ], + data: [{ type: 'post', id: 1 }], }, }, }, - ]); + }); }); - it('returns only the requested fields when there are includes', async () => { - // Create users first + 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: 'one', - posts: { - create: { title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - nickName: 'two', - posts: { - create: { title: 'Post2', published: true }, - }, + nickName: 'User 1', + posts: { create: { title: 'Post1', content: 'Post 1 Content' } }, }, }); const r = await handler({ method: 'get', - path: '/user', + path: '/user/user1', prisma, - query: { ['fields[user]']: 'email,nickName', ['fields[post]']: 'title,published', include: 'posts' }, + query: { ['fields[user]']: 'email' }, }); expect(r.status).toBe(200); - - console.log('body', JSON.stringify(r.body)); - - expect(r.body.data).toEqual([ - { - type: 'user', - id: 'user1', - attributes: { - email: 'user1@abc.com', - nickName: 'one', - }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [ - { - type: 'post', - id: 1, - }, - ], - }, - }, - }, - { - type: 'user', - id: 'user2', - attributes: { - email: 'user2@abc.com', - nickName: 'two', - }, - links: { - self: 'http://localhost/api/user/user2', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user2/relationships/posts', - related: 'http://localhost/api/user/user2/posts', - }, - data: [ - { - type: 'post', - id: 2, - }, - ], - }, - }, - }, - ]); - - expect(r.body.included).toEqual([ - { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - published: false, - }, - links: { - self: 'http://localhost/api/post/1', - }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - }, - }, + expect(r.body.data).toEqual({ + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', }, - { - type: 'post', - id: 2, - attributes: { - title: 'Post2', - published: true, - }, - links: { - self: 'http://localhost/api/post/2', - }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/2/relationships/author', - related: 'http://localhost/api/post/2/author', - }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', }, + data: [{ type: 'post', id: 1 }], }, }, - ]); + }); }); /** - it('returns a single item when the ID is specified', async () => { - // Create a user first - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, - }); - - const r = await handler({ - method: 'get', - path: '/user/user1', - prisma, - }); - - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: { - type: 'user', - id: 'user1', - attributes: { email: 'user1@abc.com' }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [{ type: 'post', id: 1 }], - }, - }, - }, - }); - }); + it('fetch a related resource', async () => { From ed379e544d22d316fe34f4e7e0775ce214d17091 Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Thu, 25 Sep 2025 12:52:40 -0400 Subject: [PATCH 4/6] add test for include on read single --- .../server/tests/api/rest-partial.test.ts | 142 ++++++++++++------ 1 file changed, 99 insertions(+), 43 deletions(-) diff --git a/packages/server/tests/api/rest-partial.test.ts b/packages/server/tests/api/rest-partial.test.ts index 2280add5d..80558e8a5 100644 --- a/packages/server/tests/api/rest-partial.test.ts +++ b/packages/server/tests/api/rest-partial.test.ts @@ -161,8 +161,6 @@ describe('REST server tests', () => { // expect(r.status).toBe(200); - // console.log('body', JSON.stringify(r.body)); - // expect(r.body.data).toEqual([ // { // type: 'user', @@ -249,8 +247,6 @@ describe('REST server tests', () => { // expect(r.status).toBe(200); - // console.log('body', JSON.stringify(r.body)); - // expect(r.body.data).toEqual([ // { // type: 'user', @@ -346,46 +342,84 @@ describe('REST server tests', () => { // ]); // }); - it('returns the full item 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' } }, - }, - }); + // it('returns the full item 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, - }); + // const r = await handler({ + // method: 'get', + // path: '/user/user1', + // prisma, + // }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: { - type: 'user', - id: 'user1', - attributes: { email: 'user1@abc.com', nickName: 'User 1' }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [{ type: 'post', id: 1 }], - }, - }, - }, - }); - }); + // expect(r.status).toBe(200); + // expect(r.body).toMatchObject({ + // data: { + // type: 'user', + // id: 'user1', + // attributes: { email: 'user1@abc.com', nickName: 'User 1' }, + // links: { + // self: 'http://localhost/api/user/user1', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user1/relationships/posts', + // related: 'http://localhost/api/user/user1/posts', + // }, + // data: [{ type: 'post', id: 1 }], + // }, + // }, + // }, + // }); + // }); - it('returns only the requested fields when the ID is specified', async () => { + // 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).toEqual({ + // type: 'user', + // id: 'user1', + // attributes: { email: 'user1@abc.com' }, + // links: { + // self: 'http://localhost/api/user/user1', + // }, + // relationships: { + // posts: { + // links: { + // self: 'http://localhost/api/user/user1/relationships/posts', + // related: 'http://localhost/api/user/user1/posts', + // }, + // data: [{ type: 'post', id: 1 }], + // }, + // }, + // }); + // }); + + 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: { @@ -400,14 +434,14 @@ describe('REST server tests', () => { method: 'get', path: '/user/user1', prisma, - query: { ['fields[user]']: 'email' }, + query: { ['fields[user]']: 'email,nickName', ['fields[post]']: 'title,published', include: 'posts' }, }); expect(r.status).toBe(200); expect(r.body.data).toEqual({ type: 'user', id: 'user1', - attributes: { email: 'user1@abc.com' }, + attributes: { email: 'user1@abc.com', nickName: 'User 1' }, links: { self: 'http://localhost/api/user/user1', }, @@ -421,6 +455,28 @@ describe('REST server tests', () => { }, }, }); + + expect(r.body.included).toEqual([ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + published: false, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, + }, + }, + ]); }); /** From 14fd00e57308a2891573b5a2ac8c2e220bd9effd Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Thu, 25 Sep 2025 14:54:24 -0400 Subject: [PATCH 5/6] implement sparse fields for relationships, add more tests --- packages/server/src/api/rest/index.ts | 13 +- .../server/tests/api/rest-partial.test.ts | 1478 ++++------------- 2 files changed, 375 insertions(+), 1116 deletions(-) diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index eb8cab8d8..7c1f6897a 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -585,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, @@ -772,8 +779,6 @@ class RequestHandler extends APIHandlerBase { } else { args.take = limit; - console.log('args', JSON.stringify(args)); - const [entities, count] = await Promise.all([ prisma[type].findMany(args), prisma[type].count({ where: args.where ?? {} }), @@ -1905,8 +1910,6 @@ class RequestHandler extends APIHandlerBase { } } - console.log('relation select:', JSON.stringify({ select: result, error: undefined, allIncludes })); - return { select: result, error: undefined, allIncludes }; } diff --git a/packages/server/tests/api/rest-partial.test.ts b/packages/server/tests/api/rest-partial.test.ts index 80558e8a5..47fc24b1f 100644 --- a/packages/server/tests/api/rest-partial.test.ts +++ b/packages/server/tests/api/rest-partial.test.ts @@ -53,371 +53,256 @@ describe('REST server tests', () => { _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - // it('returns all items and 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, - // }); - - // expect(r.status).toBe(200); - // expect(r.body).toMatchObject({ - // links: { - // self: 'http://localhost/api/user', - // }, - // meta: { - // total: 2, - // }, - // data: [ - // { - // type: 'user', - // id: 'user1', - // attributes: { email: 'user1@abc.com', nickName: 'one' }, - // links: { - // self: 'http://localhost/api/user/user1', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user1/relationships/posts', - // related: 'http://localhost/api/user/user1/posts', - // }, - // data: [{ type: 'post', id: 1 }], - // }, - // }, - // }, - // { - // type: 'user', - // id: 'user2', - // attributes: { email: 'user2@abc.com', nickName: 'two' }, - // links: { - // self: 'http://localhost/api/user/user2', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user2/relationships/posts', - // related: 'http://localhost/api/user/user2/posts', - // }, - // data: [{ type: 'post', id: 2 }], - // }, - // }, - // }, - // ], - // }); - // }); - - // 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).toEqual([ - // { - // type: 'user', - // id: 'user1', - // attributes: { - // email: 'user1@abc.com', - // nickName: 'one', - // }, - // links: { - // self: 'http://localhost/api/user/user1', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user1/relationships/posts', - // related: 'http://localhost/api/user/user1/posts', - // }, - // data: [ - // { - // type: 'post', - // id: 1, - // }, - // ], - // }, - // }, - // }, - // { - // type: 'user', - // id: 'user2', - // attributes: { - // email: 'user2@abc.com', - // nickName: 'two', - // }, - // links: { - // self: 'http://localhost/api/user/user2', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user2/relationships/posts', - // related: 'http://localhost/api/user/user2/posts', - // }, - // data: [ - // { - // type: 'post', - // id: 2, - // }, - // ], - // }, - // }, - // }, - // ]); - // }); + 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' }, + }, + }, + }); - // 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', published: true }, - // }, - // }, - // }); - // 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' }, + }); - // 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.status).toBe(200); + expect(r.body.data).toEqual([ + { + type: 'user', + id: 'user1', + attributes: { + email: 'user1@abc.com', + nickName: 'one', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'post', + id: 1, + }, + ], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { + email: 'user2@abc.com', + nickName: 'two', + }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [ + { + type: 'post', + id: 2, + }, + ], + }, + }, + }, + ]); + }); - // expect(r.body.data).toEqual([ - // { - // type: 'user', - // id: 'user1', - // attributes: { - // email: 'user1@abc.com', - // nickName: 'one', - // }, - // links: { - // self: 'http://localhost/api/user/user1', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user1/relationships/posts', - // related: 'http://localhost/api/user/user1/posts', - // }, - // data: [ - // { - // type: 'post', - // id: 1, - // }, - // ], - // }, - // }, - // }, - // { - // type: 'user', - // id: 'user2', - // attributes: { - // email: 'user2@abc.com', - // nickName: 'two', - // }, - // links: { - // self: 'http://localhost/api/user/user2', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user2/relationships/posts', - // related: 'http://localhost/api/user/user2/posts', - // }, - // data: [ - // { - // type: 'post', - // id: 2, - // }, - // ], - // }, - // }, - // }, - // ]); + 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 }, + }, + }, + }); - // expect(r.body.included).toEqual([ - // { - // type: 'post', - // id: 1, - // attributes: { - // title: 'Post1', - // published: false, - // }, - // links: { - // self: 'http://localhost/api/post/1', - // }, - // relationships: { - // author: { - // links: { - // self: 'http://localhost/api/post/1/relationships/author', - // related: 'http://localhost/api/post/1/author', - // }, - // }, - // }, - // }, - // { - // type: 'post', - // id: 2, - // attributes: { - // title: 'Post2', - // published: true, - // }, - // links: { - // self: 'http://localhost/api/post/2', - // }, - // relationships: { - // author: { - // links: { - // self: 'http://localhost/api/post/2/relationships/author', - // related: 'http://localhost/api/post/2/author', - // }, - // }, - // }, - // }, - // ]); - // }); + const r = await handler({ + method: 'get', + path: '/user', + prisma, + query: { ['fields[user]']: 'email,nickName', ['fields[post]']: 'title,published', include: 'posts' }, + }); - // it('returns the full item 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' } }, - // }, - // }); + expect(r.status).toBe(200); - // const r = await handler({ - // method: 'get', - // path: '/user/user1', - // prisma, - // }); + expect(r.body.data).toEqual([ + { + type: 'user', + id: 'user1', + attributes: { + email: 'user1@abc.com', + nickName: 'one', + }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [ + { + type: 'post', + id: 1, + }, + ], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { + email: 'user2@abc.com', + nickName: 'two', + }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [ + { + type: 'post', + id: 2, + }, + ], + }, + }, + }, + ]); - // expect(r.status).toBe(200); - // expect(r.body).toMatchObject({ - // data: { - // type: 'user', - // id: 'user1', - // attributes: { email: 'user1@abc.com', nickName: 'User 1' }, - // links: { - // self: 'http://localhost/api/user/user1', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user1/relationships/posts', - // related: 'http://localhost/api/user/user1/posts', - // }, - // data: [{ type: 'post', id: 1 }], - // }, - // }, - // }, - // }); - // }); + expect(r.body.included).toEqual([ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + published: false, + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, + }, + }, + { + type: 'post', + id: 2, + attributes: { + title: 'Post2', + published: true, + }, + links: { + self: 'http://localhost/api/post/2', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/2/relationships/author', + related: 'http://localhost/api/post/2/author', + }, + }, + }, + }, + ]); + }); - // 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' } }, - // }, - // }); + 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' }, - // }); + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + query: { ['fields[user]']: 'email' }, + }); - // expect(r.status).toBe(200); - // expect(r.body.data).toEqual({ - // type: 'user', - // id: 'user1', - // attributes: { email: 'user1@abc.com' }, - // links: { - // self: 'http://localhost/api/user/user1', - // }, - // relationships: { - // posts: { - // links: { - // self: 'http://localhost/api/user/user1/relationships/posts', - // related: 'http://localhost/api/user/user1/posts', - // }, - // data: [{ type: 'post', id: 1 }], - // }, - // }, - // }); - // }); + expect(r.status).toBe(200); + expect(r.body.data).toEqual({ + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'post', id: 1 }], + }, + }, + }); + }); it('returns only the requested fields when the ID is specified and has an include', async () => { // Create a user first @@ -479,773 +364,144 @@ describe('REST server tests', () => { ]); }); - /** - - - - it('fetch a related resource', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - - const r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, - }); - - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/posts', - }, - data: [ - { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - authorId: 'user1', - published: false, - viewCount: 0, - }, - links: { - self: 'http://localhost/api/post/1', - }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - }, - }, - }, - ], - }); - }); - - it('toplevel filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - address: { city: 'Seattle' }, - someJson: 'foo', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, - }, - }, - }); - - // id filter - let r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user2' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - // multi-id filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user1,user2' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(2); - - // String filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@bc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'ser1' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // Int filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gte]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lte]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // Boolean filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // deep to-one filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author][email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // deep to-many filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts][published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // filter to empty - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user3' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-many relation collection filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '1,2,3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - - // multi filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - r = await handler({ - method: 'get', - path: '/post', - query: { - ['filter[author][email]']: 'user1@abc.com', - ['filter[title]']: 'Post1', - }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - r = await handler({ - method: 'get', - path: '/post', - query: { - ['filter[author][email]']: 'user1@abc.com', - ['filter[title]']: 'Post2', - }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-one relation filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // relation filter with multiple values - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author]']: 'user1,user2' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - - // invalid filter field - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', - }, - ], - }); - - // invalid filter value - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: 'a' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-value', - title: 'Invalid value for type', - }, - ], - }); - - // invalid filter operation - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', - }, - ], - }); - - // typedef equality filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[address]']: JSON.stringify({ city: 'Seattle' }) }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[address]']: JSON.stringify({ city: 'Tokyo' }) }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // plain json equality filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[someJson]']: JSON.stringify('foo') }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[someJson]']: JSON.stringify('bar') }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // invalid json - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[someJson]']: '{ hello: world }' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-value', - title: 'Invalid value for type', - }, - ], - }); - }); - - it('toplevel sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', viewCount: 1, published: true }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 2, published: false }, - }, - }, - }); - - // basic sorting - let r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // basic sorting desc - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation id - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author.email' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // multi-field sorting - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'published,viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount,published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount,-published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // invalid field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', - }, - ], - }); - - // sort with collection - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'comments' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', - }, - ], - }); + 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' }, + }, + }, + }); - // sort with regular field in the middle - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount.foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', - }, - ], - }); - }); + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + query: { ['fields[post]']: 'title,content' }, + }); - it('including', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, - }, - profile: { - create: { gender: 'male' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { - id: 2, - title: 'Post2', - viewCount: 1, - published: true, - comments: { create: { content: 'Comment2' } }, - }, + expect(r.status).toBe(200); + expect(r.body.data).toEqual([ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + content: 'Post 1 Content', + }, + links: { + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', }, }, - }); - - // collection query include - let r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(2); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); - - // single query include - r = await handler({ - method: 'get', - path: '/user/user1', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); + }, + }, + ]); + }); - // related query include - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + 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 }, + }, + }, + }); - // related query include with filter - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments', ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + // 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' }); + }); - // deep include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(4); - expect(r.body.included[2]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + 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 }, + }, + }, + }); - // multiple include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments,profile' }, - prisma, - }); - expect(r.body.included).toHaveLength(5); - const profile = r.body.included.find((item: any) => item.type === 'profile'); - expect(profile).toMatchObject({ - type: 'profile', - attributes: { gender: 'male' }, - }); + // 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 }); + }); - // invalid include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [{ status: 400, code: 'unsupported-relationship' }], - }); + 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}`, + }, }); + } - it('toplevel pagination', async () => { - for (const i of Array(5).keys()) { - await prisma.user.create({ - data: { - myId: `user${i}`, - email: `user${i}@abc.com`, - }, - }); - } - - // limit only - let r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: null, - next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); - - // limit & offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3', ['page[offset]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', - next: null, - }); - - // limit trimmed - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); - - // offset overflow - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); - - // minus offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '-1' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); - - // zero limit - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); - }); - **/ + // 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', + }); + }); }); }); From f92fea4c0cb3ab4a3bfaf94ae1c5c2c079ae3378 Mon Sep 17 00:00:00 2001 From: Scott Waterhouse Date: Thu, 25 Sep 2025 15:13:14 -0400 Subject: [PATCH 6/6] remove testing shortcut --- .vscode/launch.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index f17b3c1cc..18b3b5721 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,14 +5,6 @@ { "version": "0.2.0", "configurations": [ - { - "command": "pnpm jest -- rest-partial.test.ts", - "name": "Run npm start", - "request": "launch", - "type": "node-terminal", - "cwd": "${workspaceFolder}/packages/server" - }, - { "name": "Attach", "port": 9229,