diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 8664eb184..d83b1401e 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -1,9 +1,10 @@ import type { Application, CreateApplication } from '@logto/schemas'; import { ApplicationType, Applications } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; -import { convertToIdentifiers, conditionalSql } from '@logto/shared'; -import type { CommonQueryMethods } from 'slonik'; +import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logto/shared'; +import type { CommonQueryMethods, SqlSqlToken } from 'slonik'; import { sql } from 'slonik'; +import { snakeCase } from 'snake-case'; import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js'; import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; @@ -22,15 +23,51 @@ const buildApplicationConditions = (search: Search) => { Applications.fields.id, Applications.fields.name, Applications.fields.description, + Applications.fields.type, ]; return conditionalSql( hasSearch, - () => sql`and ${buildConditionsFromSearch(search, searchFields)}` + () => + /** + * Avoid specifying the DB column type when calling the API (which is meaningless). + * Should specify the DB column type of enum type. + */ + sql`${buildConditionsFromSearch(search, searchFields, { + [Applications.fields.type]: snakeCase('ApplicationType'), + })}` + ); +}; + +const buildConditionArray = (conditions: SqlSqlToken[]) => { + const filteredConditions = conditions.filter((condition) => condition.sql !== ''); + return conditionalArraySql( + filteredConditions, + (filteredConditions) => sql`where ${sql.join(filteredConditions, sql` and `)}` ); }; export const createApplicationQueries = (pool: CommonQueryMethods) => { + const countApplications = async (search: Search) => { + const { count } = await pool.one<{ count: string }>(sql` + select count(*) + from ${table} + ${buildConditionArray([buildApplicationConditions(search)])} + `); + + return { count: Number(count) }; + }; + + const findApplications = async (search: Search, limit?: number, offset?: number) => + pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + ${buildConditionArray([buildApplicationConditions(search)])} + order by ${fields.createdAt} desc + ${conditionalSql(limit, (value) => sql`limit ${value}`)} + ${conditionalSql(offset, (value) => sql`offset ${value}`)} + `); + const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table); const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [ @@ -78,9 +115,11 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { const { count } = await pool.one<{ count: string }>(sql` select count(*) from ${table} - where ${fields.type} = ${ApplicationType.MachineToMachine} - and ${fields.id} in (${sql.join(applicationIds, sql`, `)}) - ${buildApplicationConditions(search)} + ${buildConditionArray([ + sql`${fields.type} = ${ApplicationType.MachineToMachine}`, + sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`, + buildApplicationConditions(search), + ])} `); return { count: Number(count) }; @@ -99,9 +138,11 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { return pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - where ${fields.type} = ${ApplicationType.MachineToMachine} - and ${fields.id} in (${sql.join(applicationIds, sql`, `)}) - ${buildApplicationConditions(search)} + ${buildConditionArray([ + sql`${fields.type} = ${ApplicationType.MachineToMachine}`, + sql`${fields.id} in (${sql.join(applicationIds, sql`, `)})`, + buildApplicationConditions(search), + ])} limit ${limit} offset ${offset} `); @@ -119,6 +160,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { }; return { + countApplications, + findApplications, findTotalNumberOfApplications, findAllApplications, findApplicationById, diff --git a/packages/core/src/routes/application.test.ts b/packages/core/src/routes/application.test.ts index fbe7ccb23..40aa33030 100644 --- a/packages/core/src/routes/application.test.ts +++ b/packages/core/src/routes/application.test.ts @@ -22,8 +22,8 @@ const tenantContext = new MockTenant( undefined, { applications: { - findTotalNumberOfApplications: jest.fn(async () => ({ count: 10 })), - findAllApplications: jest.fn(async () => [mockApplication]), + countApplications: jest.fn(async () => ({ count: 10 })), + findApplications: jest.fn(async () => [mockApplication]), findApplicationById, deleteApplicationById, insertApplication: jest.fn( diff --git a/packages/core/src/routes/application.ts b/packages/core/src/routes/application.ts index 5f9ef84b5..64802620e 100644 --- a/packages/core/src/routes/application.ts +++ b/packages/core/src/routes/application.ts @@ -14,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import { buildOidcClientMetadata } from '#src/oidc/utils.js'; import assertThat from '#src/utils/assert-that.js'; +import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; @@ -34,10 +35,10 @@ export default function applicationRoutes( const { deleteApplicationById, findApplicationById, - findAllApplications, insertApplication, updateApplicationById, - findTotalNumberOfApplications, + countApplications, + findApplications, } = queries.applications; const { findApplicationsRolesByApplicationId, insertApplicationsRoles, deleteApplicationRole } = queries.applicationsRoles; @@ -46,19 +47,25 @@ export default function applicationRoutes( router.get( '/applications', koaPagination({ isOptional: true }), - koaGuard({ response: z.array(Applications.guard), status: 200 }), + koaGuard({ + response: z.array(Applications.guard), + status: 200, + }), async (ctx, next) => { const { limit, offset, disabled: paginationDisabled } = ctx.pagination; + const { searchParams } = ctx.URL; + + const search = parseSearchParamsForSearch(searchParams); if (paginationDisabled) { - ctx.body = await findAllApplications(); + ctx.body = await findApplications(search); return next(); } const [{ count }, applications] = await Promise.all([ - findTotalNumberOfApplications(), - findAllApplications(limit, offset), + countApplications(search), + findApplications(search, limit, offset), ]); // Return totalCount to pagination middleware diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts index 947724f84..c23d6edf6 100644 --- a/packages/core/src/utils/search.ts +++ b/packages/core/src/utils/search.ts @@ -1,6 +1,6 @@ import { SearchJointMode, SearchMatchMode } from '@logto/schemas'; import type { Nullable, Optional } from '@silverhand/essentials'; -import { yes, conditionalString } from '@silverhand/essentials'; +import { yes, conditionalString, conditional } from '@silverhand/essentials'; import { sql } from 'slonik'; import { snakeCase } from 'snake-case'; @@ -190,6 +190,43 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean) } }; +const validateAndBuildValueExpression = ( + rawValues: string[], + field: string, + shouldLowercase: boolean, + fieldsTypeMapping?: Record +) => { + const values = + shouldLowercase && isLowercaseValid(field, fieldsTypeMapping) + ? rawValues.map((rawValue) => rawValue.toLowerCase()) + : rawValues; + + // Type check for the first value + assertThat( + values[0] && values.every(Boolean), + new TypeError(`Empty value found${conditionalString(field && ` for field ${field}`)}.`) + ); + + const valueExpression = + values.length === 1 + ? sql`${values[0]}` + : sql`any(${sql.array(values, conditional(fieldsTypeMapping?.[field]) ?? 'varchar')})`; + + return valueExpression; +}; + +const isLowercaseValid = (field: string, fieldsTypeMapping?: Record) => { + return !conditional(fieldsTypeMapping?.[field]); +}; + +const showLowercase = ( + shouldLowercase: boolean, + field: string, + fieldsTypeMapping?: Record +) => { + return shouldLowercase && isLowercaseValid(field, fieldsTypeMapping); +}; + /** * Build search SQL token by parsing the search object and available search fields. * Note all `field`s will be normalized to snake case, so camel case fields are valid. @@ -200,11 +237,15 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean) * @returns The SQL token that includes the all condition checks. * @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty). */ -export const buildConditionsFromSearch = (search: Search, searchFields: string[]) => { +export const buildConditionsFromSearch = ( + search: Search, + searchFields: string[], + fieldsTypeMapping?: Record +) => { assertThat(searchFields.length > 0, new TypeError('No search field found.')); const { matches, joint, isCaseSensitive } = search; - const conditions = matches.map(({ mode, field: rawField, values: rawValues }) => { + const conditions = matches.map(({ mode, field: rawField, values }) => { const field = rawField && snakeCase(rawField); if (field && !searchFields.includes(field)) { @@ -215,23 +256,21 @@ export const buildConditionsFromSearch = (search: Search, searchFields: string[] const shouldLowercase = !isCaseSensitive && mode === SearchMatchMode.Exact; const fields = field ? [field] : searchFields; - const values = shouldLowercase ? rawValues.map((value) => value.toLowerCase()) : rawValues; - // Type check for the first value - assertThat( - values[0] && values.every(Boolean), - new TypeError(`Empty value found${conditionalString(field && ` for field ${field}`)}.`) - ); - - const valueExpression = - values.length === 1 ? sql`${values[0]}` : sql`any(${sql.array(values, 'varchar')})`; + const getValueExpressionFor = (fieldName: string, shouldLowercase: boolean) => + validateAndBuildValueExpression(values, fieldName, shouldLowercase, fieldsTypeMapping); return sql`(${sql.join( fields.map( (field) => sql`${ - shouldLowercase ? sql`lower(${sql.identifier([field])})` : sql.identifier([field]) - } ${getMatchModeOperator(mode, isCaseSensitive)} ${valueExpression}` + showLowercase(shouldLowercase, field, fieldsTypeMapping) + ? sql`lower(${sql.identifier([field])})` + : sql.identifier([field]) + } ${getMatchModeOperator(mode, isCaseSensitive)} ${getValueExpressionFor( + field, + shouldLowercase + )}` ), sql` or ` )})`; diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index cad136fff..2519506f7 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -1,10 +1,11 @@ -import type { - Application, - CreateApplication, - ApplicationType, - OidcClientMetadata, - Role, +import { + type Application, + type CreateApplication, + type ApplicationType, + type OidcClientMetadata, + type Role, } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { authedAdminApi } from './api.js'; @@ -23,7 +24,16 @@ export const createApplication = async ( }) .json(); -export const getApplications = async () => authedAdminApi.get('applications').json(); +export const getApplications = async (types?: ApplicationType[]) => { + const searchParams = new URLSearchParams( + conditional( + types && + types.length > 0 && [...types.map((type) => ['search.type', type]), ['mode.type', 'exact']] + ) + ); + + return authedAdminApi.get('applications', { searchParams }).json(); +}; export const getApplication = async (applicationId: string) => authedAdminApi.get(`applications/${applicationId}`).json(); diff --git a/packages/integration-tests/src/tests/api/application.test.ts b/packages/integration-tests/src/tests/api/application.test.ts index 8752e45a6..3420e203b 100644 --- a/packages/integration-tests/src/tests/api/application.test.ts +++ b/packages/integration-tests/src/tests/api/application.test.ts @@ -81,6 +81,32 @@ describe('admin console application', () => { expect(applicationNames).toContain('test-update-app'); }); + it('should create m2m application successfully and can get only m2m applications by specifying types', async () => { + await createApplication('test-m2m-app-1', ApplicationType.MachineToMachine); + await createApplication('test-m2m-app-2', ApplicationType.MachineToMachine); + const m2mApps = await getApplications([ApplicationType.MachineToMachine]); + const m2mAppNames = m2mApps.map(({ name }) => name); + expect(m2mAppNames).toContain('test-m2m-app-1'); + expect(m2mAppNames).toContain('test-m2m-app-2'); + }); + + it('total number of apps should equal to the sum of number of each types', async () => { + const allApps = await getApplications(); + const m2mApps = await getApplications([ApplicationType.MachineToMachine]); + const spaApps = await getApplications([ApplicationType.SPA]); + const otherApps = await getApplications([ApplicationType.Native, ApplicationType.Traditional]); + expect(allApps.length).toBe(m2mApps.length + spaApps.length + otherApps.length); + const allAppNames = allApps.map(({ name }) => name); + const spaAppNames = spaApps.map(({ name }) => name); + const otherAppNames = otherApps.map(({ name }) => name); + expect(allAppNames).toContain('test-m2m-app-1'); + expect(allAppNames).toContain('test-m2m-app-2'); + expect(spaAppNames).not.toContain('test-m2m-app-1'); + expect(spaAppNames).not.toContain('test-m2m-app-2'); + expect(otherAppNames).not.toContain('test-m2m-app-1'); + expect(otherAppNames).not.toContain('test-m2m-app-2'); + }); + it('should delete application successfully', async () => { const application = await createApplication('test-delete-app', ApplicationType.Native);