diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index 940c7c44f..399ea3ebe 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -3,7 +3,7 @@ import { sql } from 'slonik'; export const buildGetTotalRowCountWithPool = (pool: CommonQueryMethods, table: string) => async () => { - // Postgres returns a biging for count(*), which is then converted to a string by query library. + // Postgres returns a bigint for count(*), which is then converted to a string by query library. // We need to convert it to a number. const { count } = await pool.one<{ count: string }>(sql` select count(*) @@ -15,7 +15,7 @@ export const buildGetTotalRowCountWithPool = export const getTotalRowCountWithPool = (pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => { - // Postgres returns a biging for count(*), which is then converted to a string by query library. + // Postgres returns a bigint for count(*), which is then converted to a string by query library. // We need to convert it to a number. const { count } = await pool.one<{ count: string }>(sql` select count(*) diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 6eb4ba452..aa584885d 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js'; import { type AuthedRouter, type RouterInitArgs } from '../types.js'; @@ -28,22 +29,29 @@ export default function organizationRoutes(...args: Rout router.get( pathname, + koaPagination(), koaGuard({ params: z.object(params), response: OrganizationRoles.guard.array(), status: [200, 404], }), - // TODO: Add pagination async (ctx, next) => { const { id, userId } = ctx.guard.params; // Ensure both the organization and the role exist await Promise.all([organizations.findById(id), users.findUserById(userId)]); - ctx.body = await organizations.relations.rolesUsers.getEntries(OrganizationRoles, { - organizationId: id, - userId, - }); + const [totalCount, entities] = await organizations.relations.rolesUsers.getEntities( + OrganizationRoles, + { + organizationId: id, + userId, + }, + ctx.pagination + ); + + ctx.pagination.totalCount = totalCount; + ctx.body = entities; return next(); } ); diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts index b2dfa1232..28689fe5b 100644 --- a/packages/core/src/utils/RelationQueries.ts +++ b/packages/core/src/utils/RelationQueries.ts @@ -1,3 +1,4 @@ +import { conditionalSql } from '@logto/shared'; import { type KeysToCamelCase } from '@silverhand/essentials'; import { sql, type CommonQueryMethods } from 'slonik'; import snakecaseKeys from 'snakecase-keys'; @@ -17,8 +18,13 @@ type CamelCaseIdObject = KeysToCamelCase<{ [Key in `${T}_id`]: string; }>; +type GetEntitiesOptions = { + limit?: number; + offset?: number; +}; + /** - * Query class for relation tables that connect several tables by their entry ids. + * Query class for relation tables that connect several tables by their entity ids. * * Let's say we have two tables `users` and `groups` and a relation table * `user_group_relations`. Then we can create a `RelationQueries` instance like this: @@ -41,13 +47,13 @@ type CamelCaseIdObject = KeysToCamelCase<{ * ); * ``` * - * To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method: + * To get all entities for a specific table, we can use the {@link RelationQueries.getEntities} method: * * ```ts - * await userGroupRelations.getEntries(Users, { groupId: 'group-id-1' }); + * await userGroupRelations.getEntities(Users, { groupId: 'group-id-1' }); * ``` * - * This will return all entries for the `users` table that are connected to the + * This will return all entities for the `users` table that are connected to the * group with the id `group-id-1`. */ export default class RelationQueries< @@ -75,12 +81,12 @@ export default class RelationQueries< } /** - * Insert new entries into the relation table. + * Insert new entities into the relation table. * - * Each entry must contain the same number of ids as the number of relations, and + * Each entity must contain the same number of ids as the number of relations, and * the order of the ids must match the order of the relations. * - * @param data Entries to insert. + * @param data Entities to insert. * @returns A Promise that resolves to the query result. * * @example @@ -117,7 +123,7 @@ export default class RelationQueries< /** * Delete a relation from the relation table. * - * @param data The ids of the entries to delete. The keys must be in camel case + * @param data The ids of the entities to delete. The keys must be in camel case * and end with `Id`. * @returns A Promise that resolves to the query result. * @@ -141,29 +147,34 @@ export default class RelationQueries< } /** - * Get all entries for a specific schema that are connected to the given ids. + * Get all entities for a specific schema that are connected to the given ids. * - * @param forSchema The schema to get the entries for. - * @param where Other ids to filter the entries by. The keys must be in camel case + * @param forSchema The schema to get the entities for. + * @param where Other ids to filter the entities by. The keys must be in camel case * and end with `Id`. - * @returns A Promise that resolves an array of entries of the given schema. + * @param options Options for the query. + * @param options.limit The maximum number of entities to return. + * @param options.offset The number of entities to skip. + * @returns A Promise that resolves an array of entities of the given schema. * * @example * ```ts * const userGroupRelations = new RelationQueries(pool, 'user_group_relations', Users, Groups); * - * userGroupRelations.getEntries(Users, { groupId: 'group-id-1' }); + * userGroupRelations.getEntities(Users, { groupId: 'group-id-1' }); + * // With pagination + * userGroupRelations.getEntities(Users, { groupId: 'group-id-1' }, { limit: 10, offset: 20 }); * ``` */ - async getEntries( + async getEntities( forSchema: S, - where: CamelCaseIdObject> - ): Promise>> { + where: CamelCaseIdObject>, + options: GetEntitiesOptions = {} + ): Promise<[totalCount: number, entities: ReadonlyArray>]> { + const { limit, offset } = options; const snakeCaseWhere = snakecaseKeys(where); const forTable = sql.identifier([forSchema.table]); - - const { rows } = await this.pool.query>(sql` - select ${forTable}.* + const mainSql = sql` from ${this.table} join ${forTable} on ${sql.identifier([ this.relationTable, @@ -174,16 +185,30 @@ export default class RelationQueries< ([column, value]) => sql`${sql.identifier([column])} = ${value}` ), sql` and ` - )}; - `); + )} + `; - return rows; + const [{ count }, { rows }] = await Promise.all([ + // Postgres returns a bigint for count(*), which is then converted to a string by query library. + // We need to convert it to a number. + this.pool.one<{ count: string }>(sql` + select count(*) + ${mainSql} + `), + this.pool.query>(sql` + select ${forTable}.* ${mainSql} + ${conditionalSql(limit, (limit) => sql`limit ${limit}`)} + ${conditionalSql(offset, (offset) => sql`offset ${offset}`)} + `), + ]); + + return [Number(count), rows]; } /** * Check if a relation exists. * - * @param ids The ids of the entries to check. The order of the ids must match the order of the relations. + * @param ids The ids of the entities to check. The order of the ids must match the order of the relations. * @returns A Promise that resolves to `true` if the relation exists, otherwise `false`. * * @example diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index ca1c63a75..d4439dff1 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -50,13 +50,15 @@ export class SchemaActions< * * @param pagination The request pagination info parsed from `koa-pagination`. The * function should honor the pagination info and return the correct entities. - * @returns A tuple of `[count, entities]`. `count` is the total count of entities - * in the database; `entities` is the list of entities to be returned. + * @returns A tuple of `[totalCount, entities]`. `totalCount` is the total count of + * entities in the database; `entities` is the list of entities to be returned. */ public async get({ limit, offset, - }: Pick): Promise<[count: number, entities: readonly Schema[]]> { + }: Pick): Promise< + [totalCount: number, entries: readonly Schema[]] + > { return Promise.all([this.queries.findTotalNumber(), this.queries.findAll(limit, offset)]); } @@ -289,9 +291,9 @@ export default class SchemaRouter< relationSchemaIds: camelCaseSchemaId(relationSchema) + 's', }; - // TODO: Add pagination support this.get( `/:id/${pathname}`, + koaPagination(), koaGuard({ params: z.object({ id: z.string().min(1) }), response: relationSchema.guard.array(), @@ -303,9 +305,16 @@ export default class SchemaRouter< // Ensure that the main entry exists await this.actions.getById(id); - ctx.body = await relationQueries.getEntries(relationSchema, { - [columns.schemaId]: id, - }); + const [totalCount, entities] = await relationQueries.getEntities( + relationSchema, + { + [columns.schemaId]: id, + }, + ctx.pagination + ); + + ctx.pagination.totalCount = totalCount; + ctx.body = entities; return next(); } ); diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts index 7882e7f87..ddb6c9b6f 100644 --- a/packages/integration-tests/src/api/organization-role.ts +++ b/packages/integration-tests/src/api/organization-role.ts @@ -15,8 +15,10 @@ class OrganizationRoleApi extends ApiFactory< await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { organizationScopeIds } }); } - async getScopes(id: string): Promise { - return authedAdminApi.get(`${this.path}/${id}/scopes`).json(); + async getScopes(id: string, searchParams?: URLSearchParams): Promise { + return authedAdminApi + .get(`${this.path}/${id}/scopes`, { searchParams }) + .json(); } async deleteScope(id: string, scopeId: string): Promise { diff --git a/packages/integration-tests/src/tests/api/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization-role.test.ts index c84b66c8e..271314e8a 100644 --- a/packages/integration-tests/src/tests/api/organization-role.test.ts +++ b/packages/integration-tests/src/tests/api/organization-role.test.ts @@ -31,33 +31,32 @@ describe('organization role APIs', () => { it('should be able to create a role with some scopes', async () => { const name = 'test' + randomId(); - const [scope1, scope2] = await Promise.all([ - scopeApi.create({ name: 'test' + randomId() }), - scopeApi.create({ name: 'test' + randomId() }), - ]); - const organizationScopeIds = [scope1.id, scope2.id]; + const scopes = await Promise.all( + // Create 30 scopes to exceed the default page size + Array.from({ length: 30 }).map(async () => scopeApi.create({ name: 'test' + randomId() })) + ); + const organizationScopeIds = scopes.map((scope) => scope.id); const role = await roleApi.create({ name, organizationScopeIds }); - expect(role).toStrictEqual( - expect.objectContaining({ - name, + const roleScopes = await roleApi.getScopes(role.id); + expect(roleScopes).toHaveLength(20); + expect(roleScopes[0]?.id).not.toBeFalsy(); + expect(roleScopes[0]?.id).toBe(scopes[0]?.id); + + const roleScopes2 = await roleApi.getScopes( + role.id, + new URLSearchParams({ + page: '2', + page_size: '20', }) ); - // Check scopes under a role after API is implemented - const scopes = await roleApi.getScopes(role.id); - expect(scopes).toContainEqual( - expect.objectContaining({ - name: scope1.name, - }) - ); - expect(scopes).toContainEqual( - expect.objectContaining({ - name: scope2.name, - }) - ); + expect(roleScopes2).toHaveLength(10); + expect(roleScopes2[0]?.id).not.toBeFalsy(); + expect(roleScopes2[0]?.id).toBe(scopes[20]?.id); - await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]); + await Promise.all(scopes.map(async (scope) => scopeApi.delete(scope.id))); + await roleApi.delete(role.id); }); it('should get organization roles successfully', async () => {