mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
refactor(core): add pagination to relation queries
This commit is contained in:
parent
d792e754a2
commit
a3a4c8a431
6 changed files with 103 additions and 60 deletions
|
@ -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(*)
|
||||
|
|
|
@ -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<T extends AuthedRouter>(...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();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<T extends string> = 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<T extends string> = 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<S extends Schemas[number]>(
|
||||
async getEntities<S extends Schemas[number]>(
|
||||
forSchema: S,
|
||||
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>
|
||||
): Promise<ReadonlyArray<InferSchema<S>>> {
|
||||
where: CamelCaseIdObject<Exclude<Schemas[number]['tableSingular'], S['tableSingular']>>,
|
||||
options: GetEntitiesOptions = {}
|
||||
): Promise<[totalCount: number, entities: ReadonlyArray<InferSchema<S>>]> {
|
||||
const { limit, offset } = options;
|
||||
const snakeCaseWhere = snakecaseKeys(where);
|
||||
const forTable = sql.identifier([forSchema.table]);
|
||||
|
||||
const { rows } = await this.pool.query<InferSchema<S>>(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<InferSchema<S>>(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
|
||||
|
|
|
@ -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<Pagination, 'limit' | 'offset'>): Promise<[count: number, entities: readonly Schema[]]> {
|
||||
}: Pick<Pagination, 'limit' | 'offset'>): 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();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -15,8 +15,10 @@ class OrganizationRoleApi extends ApiFactory<
|
|||
await authedAdminApi.post(`${this.path}/${id}/scopes`, { json: { organizationScopeIds } });
|
||||
}
|
||||
|
||||
async getScopes(id: string): Promise<OrganizationScope[]> {
|
||||
return authedAdminApi.get(`${this.path}/${id}/scopes`).json<OrganizationScope[]>();
|
||||
async getScopes(id: string, searchParams?: URLSearchParams): Promise<OrganizationScope[]> {
|
||||
return authedAdminApi
|
||||
.get(`${this.path}/${id}/scopes`, { searchParams })
|
||||
.json<OrganizationScope[]>();
|
||||
}
|
||||
|
||||
async deleteScope(id: string, scopeId: string): Promise<void> {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
Loading…
Add table
Reference in a new issue