mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
Merge pull request #4698 from logto-io/gao-console-org-5
feat(core): schema with search fields
This commit is contained in:
commit
36d0807d7a
10 changed files with 98 additions and 34 deletions
|
@ -2,6 +2,8 @@ import { type GeneratedSchema, type SchemaLike } from '@logto/schemas';
|
|||
import { conditionalSql, convertToIdentifiers, manyRows } from '@logto/shared';
|
||||
import { sql, type CommonQueryMethods } from 'slonik';
|
||||
|
||||
import { buildSearchSql, type SearchOptions } from './utils.js';
|
||||
|
||||
export const buildFindAllEntitiesWithPool =
|
||||
(pool: CommonQueryMethods) =>
|
||||
<
|
||||
|
@ -17,11 +19,16 @@ export const buildFindAllEntitiesWithPool =
|
|||
) => {
|
||||
const { table, fields } = convertToIdentifiers(schema);
|
||||
|
||||
return async (limit?: number, offset?: number) =>
|
||||
return async <SearchKeys extends Keys>(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
search?: SearchOptions<SearchKeys>
|
||||
) =>
|
||||
manyRows(
|
||||
pool.query<Schema>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
${buildSearchSql(search)}
|
||||
${conditionalSql(orderBy, (orderBy) => {
|
||||
const orderBySql = orderBy.map(({ field, order }) =>
|
||||
// Note: 'desc' and 'asc' are keywords, so we don't pass them as values
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import { type SearchOptions, buildSearchSql } from './utils.js';
|
||||
|
||||
export const buildGetTotalRowCountWithPool =
|
||||
(pool: CommonQueryMethods, table: string) => async () => {
|
||||
(pool: CommonQueryMethods, table: string) =>
|
||||
async <SearchKeys extends string>(search?: SearchOptions<SearchKeys>) => {
|
||||
// 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(*)
|
||||
from ${sql.identifier([table])}
|
||||
${buildSearchSql(search)}
|
||||
`);
|
||||
|
||||
return { count: Number(count) };
|
||||
|
|
23
packages/core/src/database/utils.ts
Normal file
23
packages/core/src/database/utils.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { conditionalSql } from '@logto/shared';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
/**
|
||||
* Options for searching for a string within a set of fields (case-insensitive).
|
||||
*
|
||||
* Note: `id` is excluded from the fields since it should be unique.
|
||||
*/
|
||||
export type SearchOptions<Keys extends string> = {
|
||||
fields: ReadonlyArray<Exclude<Keys, 'id'>>;
|
||||
keyword: string;
|
||||
};
|
||||
|
||||
export const buildSearchSql = <SearchKeys extends string>(search?: SearchOptions<SearchKeys>) => {
|
||||
return conditionalSql(search, (search) => {
|
||||
const { fields: searchFields, keyword } = search;
|
||||
const searchSql = sql.join(
|
||||
searchFields.map((field) => sql`${sql.identifier([field])} ilike ${`%${keyword}%`}`),
|
||||
sql` or `
|
||||
);
|
||||
return sql`where ${searchSql}`;
|
||||
});
|
||||
};
|
|
@ -101,8 +101,11 @@ class OrganizationRolesQueries extends SchemaQueries<
|
|||
override async findAll(
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<Readonly<OrganizationRoleWithScopes[]>> {
|
||||
return this.pool.any(this.#findWithScopesSql(undefined, limit, offset));
|
||||
): Promise<[totalNumber: number, rows: Readonly<OrganizationRoleWithScopes[]>]> {
|
||||
return Promise.all([
|
||||
this.findTotalNumber(),
|
||||
this.pool.any(this.#findWithScopesSql(undefined, limit, offset)),
|
||||
]);
|
||||
}
|
||||
|
||||
#findWithScopesSql(roleId?: string, limit = 1, offset = 0) {
|
||||
|
|
|
@ -21,6 +21,7 @@ export default function organizationRoutes<T extends AuthedRouter>(...args: Rout
|
|||
] = args;
|
||||
const router = new SchemaRouter(Organizations, organizations, {
|
||||
errorHandler,
|
||||
searchFields: ['name'],
|
||||
});
|
||||
|
||||
router.addRelationRoutes(organizations.relations.users);
|
||||
|
|
|
@ -21,8 +21,8 @@ export default function organizationRoleRoutes<T extends AuthedRouter>(
|
|||
) {
|
||||
const router = new SchemaRouter(OrganizationRoles, roles, {
|
||||
errorHandler,
|
||||
searchFields: ['name'],
|
||||
});
|
||||
|
||||
router.addRelationRoutes(rolesScopes, 'scopes');
|
||||
|
||||
originalRouter.use(router.routes());
|
||||
|
|
|
@ -16,7 +16,10 @@ export default function organizationScopeRoutes<T extends AuthedRouter>(
|
|||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const router = new SchemaRouter(OrganizationScopes, scopes, { errorHandler });
|
||||
const router = new SchemaRouter(OrganizationScopes, scopes, {
|
||||
errorHandler,
|
||||
searchFields: ['name'],
|
||||
});
|
||||
|
||||
originalRouter.use(router.routes());
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'
|
|||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { buildGetTotalRowCountWithPool } from '#src/database/row-count.js';
|
||||
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
|
||||
import { type SearchOptions } from '#src/database/utils.js';
|
||||
|
||||
/**
|
||||
* Query class that contains all the necessary CRUD queries for a schema. It is
|
||||
|
@ -19,8 +20,16 @@ export default class SchemaQueries<
|
|||
CreateSchema extends Partial<SchemaLike<Key> & { id: string }>,
|
||||
Schema extends SchemaLike<Key> & { id: string },
|
||||
> {
|
||||
#findTotalNumber: () => Promise<{ count: number }>;
|
||||
#findAll: (limit: number, offset: number) => Promise<readonly Schema[]>;
|
||||
#findTotalNumber: <SearchKey extends Key>(
|
||||
search?: SearchOptions<SearchKey>
|
||||
) => Promise<{ count: number }>;
|
||||
|
||||
#findAll: <SearchKey extends Key>(
|
||||
limit: number,
|
||||
offset: number,
|
||||
search?: SearchOptions<SearchKey>
|
||||
) => Promise<readonly Schema[]>;
|
||||
|
||||
#findById: (id: string) => Promise<Readonly<Schema>>;
|
||||
#insert: (data: OmitAutoSetFields<CreateSchema>) => Promise<Readonly<Schema>>;
|
||||
|
||||
|
@ -43,13 +52,12 @@ export default class SchemaQueries<
|
|||
this.#deleteById = buildDeleteByIdWithPool(this.pool, this.schema.table);
|
||||
}
|
||||
|
||||
async findTotalNumber(): Promise<number> {
|
||||
const { count } = await this.#findTotalNumber();
|
||||
return count;
|
||||
}
|
||||
|
||||
async findAll(limit: number, offset: number): Promise<readonly Schema[]> {
|
||||
return this.#findAll(limit, offset);
|
||||
async findAll<SearchKey extends Key>(
|
||||
limit: number,
|
||||
offset: number,
|
||||
search?: SearchOptions<SearchKey>
|
||||
): Promise<[totalNumber: number, rows: readonly Schema[]]> {
|
||||
return Promise.all([this.findTotalNumber(search), this.#findAll(limit, offset, search)]);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Readonly<Schema>> {
|
||||
|
@ -71,4 +79,9 @@ export default class SchemaQueries<
|
|||
async deleteById(id: string): Promise<void> {
|
||||
await this.#deleteById(id);
|
||||
}
|
||||
|
||||
protected async findTotalNumber(search?: SearchOptions<Key>): Promise<number> {
|
||||
const { count } = await this.#findTotalNumber(search);
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,8 +38,7 @@ describe('SchemaRouter', () => {
|
|||
] as const satisfies readonly Schema[];
|
||||
const queries = new SchemaQueries(createTestPool(undefined, { id: '1' }), schema);
|
||||
|
||||
jest.spyOn(queries, 'findTotalNumber').mockResolvedValue(entities.length);
|
||||
jest.spyOn(queries, 'findAll').mockResolvedValue(entities);
|
||||
jest.spyOn(queries, 'findAll').mockResolvedValue([entities.length, entities]);
|
||||
jest.spyOn(queries, 'findById').mockImplementation(async (id) => {
|
||||
const entity = entities.find((entity) => entity.id === id);
|
||||
if (!entity) {
|
||||
|
@ -67,16 +66,14 @@ describe('SchemaRouter', () => {
|
|||
it('should be able to get all entities', async () => {
|
||||
const response = await request.get(baseRoute);
|
||||
|
||||
expect(queries.findAll).toHaveBeenCalledWith(20, 0);
|
||||
expect(queries.findTotalNumber).toHaveBeenCalled();
|
||||
expect(queries.findAll).toHaveBeenCalledWith(20, 0, undefined);
|
||||
expect(response.body).toStrictEqual(entities);
|
||||
});
|
||||
|
||||
it('should be able to get all entities with pagination', async () => {
|
||||
const response = await request.get(`${baseRoute}?page=1&page_size=10`);
|
||||
|
||||
expect(queries.findAll).toHaveBeenCalledWith(10, 0);
|
||||
expect(queries.findTotalNumber).toHaveBeenCalled();
|
||||
expect(queries.findAll).toHaveBeenCalledWith(10, 0, undefined);
|
||||
expect(response.body).toStrictEqual(entities);
|
||||
expect(response.header).toHaveProperty('total-number', '2');
|
||||
});
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { type SchemaLike, type GeneratedSchema } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { type DeepPartial } from '@silverhand/essentials';
|
||||
import { cond, type Optional, type DeepPartial } from '@silverhand/essentials';
|
||||
import camelcase from 'camelcase';
|
||||
import deepmerge from 'deepmerge';
|
||||
import Router, { type IRouterParamContext } from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type SearchOptions } from '#src/database/utils.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
|
||||
|
@ -34,7 +35,7 @@ const tableToPathname = (tableName: string) => tableName.replaceAll('_', '-');
|
|||
const camelCaseSchemaId = <T extends { tableSingular: Table }, Table extends string>(schema: T) =>
|
||||
`${camelcase(schema.tableSingular)}Id` as const;
|
||||
|
||||
type SchemaRouterConfig = {
|
||||
type SchemaRouterConfig<Key extends string> = {
|
||||
/** Disable certain routes for the router. */
|
||||
disabled: {
|
||||
/** Disable `GET /` route. */
|
||||
|
@ -48,7 +49,10 @@ type SchemaRouterConfig = {
|
|||
/** Disable `DELETE /:id` route. */
|
||||
deleteById: boolean;
|
||||
};
|
||||
/** A custom error handler for the router before throwing the error. */
|
||||
errorHandler?: (error: unknown) => void;
|
||||
/** The fields that can be searched for the `GET /` route. */
|
||||
searchFields: SearchOptions<Key>['fields'];
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -63,8 +67,6 @@ type SchemaRouterConfig = {
|
|||
* - `DELETE /:id`: Delete an entity by ID.
|
||||
*
|
||||
* Browse the source code for more details about request/response validation.
|
||||
*
|
||||
* @see {@link SchemaActions} for the `actions` configuration.
|
||||
*/
|
||||
export default class SchemaRouter<
|
||||
Key extends string,
|
||||
|
@ -73,16 +75,16 @@ export default class SchemaRouter<
|
|||
StateT = unknown,
|
||||
CustomT extends IRouterParamContext = IRouterParamContext,
|
||||
> extends Router<StateT, CustomT> {
|
||||
public readonly config: SchemaRouterConfig;
|
||||
public readonly config: SchemaRouterConfig<Key>;
|
||||
|
||||
constructor(
|
||||
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
|
||||
public readonly queries: SchemaQueries<Key, CreateSchema, Schema>,
|
||||
config: DeepPartial<SchemaRouterConfig> = {}
|
||||
config: DeepPartial<SchemaRouterConfig<Key>> = {}
|
||||
) {
|
||||
super({ prefix: '/' + tableToPathname(schema.table) });
|
||||
|
||||
this.config = deepmerge<SchemaRouterConfig, DeepPartial<SchemaRouterConfig>>(
|
||||
this.config = deepmerge<typeof this.config, DeepPartial<typeof this.config>>(
|
||||
{
|
||||
disabled: {
|
||||
get: false,
|
||||
|
@ -91,6 +93,7 @@ export default class SchemaRouter<
|
|||
patchById: false,
|
||||
deleteById: false,
|
||||
},
|
||||
searchFields: [],
|
||||
},
|
||||
config
|
||||
);
|
||||
|
@ -106,19 +109,29 @@ export default class SchemaRouter<
|
|||
});
|
||||
}
|
||||
|
||||
const { disabled } = this.config;
|
||||
const { disabled, searchFields } = this.config;
|
||||
|
||||
if (!disabled.get) {
|
||||
this.get(
|
||||
'/',
|
||||
koaPagination(),
|
||||
koaGuard({ response: schema.guard.array(), status: [200] }),
|
||||
koaGuard({
|
||||
query: z.object({ q: z.string().optional() }),
|
||||
response: schema.guard.array(),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { q } = ctx.guard.query;
|
||||
const search: Optional<SearchOptions<Key>> = cond(
|
||||
q &&
|
||||
searchFields.length > 0 && {
|
||||
fields: searchFields,
|
||||
keyword: q,
|
||||
}
|
||||
);
|
||||
const { limit, offset } = ctx.pagination;
|
||||
const [count, entities] = await Promise.all([
|
||||
queries.findTotalNumber(),
|
||||
queries.findAll(limit, offset),
|
||||
]);
|
||||
const [count, entities] = await queries.findAll(limit, offset, search);
|
||||
|
||||
ctx.pagination.totalCount = count;
|
||||
ctx.body = entities;
|
||||
return next();
|
||||
|
|
Loading…
Reference in a new issue