diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 2a5d86b13..beae5eaf1 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -20,4 +20,5 @@ export type FindManyData = { export type UpdateWhereData = { set: Partial; where: Partial; + jsonbMode: 'replace' | 'merge'; }; diff --git a/packages/core/src/database/update-where.test.ts b/packages/core/src/database/update-where.test.ts index edfc3e87c..f99778e42 100644 --- a/packages/core/src/database/update-where.test.ts +++ b/packages/core/src/database/update-where.test.ts @@ -1,9 +1,10 @@ -import { CreateUser, Users, Applications } from '@logto/schemas'; +import { CreateUser, Users, Applications, User } from '@logto/schemas'; import envSet from '@/env-set'; import { UpdateError } from '@/errors/SlonikError'; import { createTestPool } from '@/utils/test-utils'; +import { UpdateWhereData } from './types'; import { buildUpdateWhere } from './update-where'; const poolSpy = jest.spyOn(envSet, 'pool', 'get'); @@ -20,6 +21,7 @@ describe('buildUpdateWhere()', () => { updateWhere({ set: { username: '123' }, where: { id: 'foo', username: '456' }, + jsonbMode: 'merge', }) ).resolves.toBe(undefined); }); @@ -47,6 +49,7 @@ describe('buildUpdateWhere()', () => { updateWhere({ set: { username: '123', primaryEmail: 'foo@bar.com', applicationId: 'bar' }, where: { id: 'foo' }, + jsonbMode: 'merge', }) ).resolves.toStrictEqual(user); }); @@ -66,6 +69,7 @@ describe('buildUpdateWhere()', () => { updateWhere({ set: { customClientMetadata: { idTokenTtl: 3600 } }, where: { id: 'foo' }, + jsonbMode: 'merge', }) ).resolves.toStrictEqual({ id: 'foo', customClientMetadata: '{"idTokenTtl":3600}' }); }); @@ -82,6 +86,7 @@ describe('buildUpdateWhere()', () => { updateWhere({ set: { username: '123', id: undefined }, where: { id: 'foo', username: '456' }, + jsonbMode: 'merge', }) ).rejects.toMatchError(new Error(`Cannot convert id to primitive`)); }); @@ -91,7 +96,11 @@ describe('buildUpdateWhere()', () => { poolSpy.mockReturnValue(pool); const updateWhere = buildUpdateWhere(Users, true); - const updateWhereData = { set: { username: '123' }, where: { id: 'foo' } }; + const updateWhereData: UpdateWhereData = { + set: { username: '123' }, + where: { id: 'foo' }, + jsonbMode: 'merge', + }; await expect(updateWhere(updateWhereData)).rejects.toMatchError( new UpdateError(Users, updateWhereData) @@ -105,8 +114,14 @@ describe('buildUpdateWhere()', () => { poolSpy.mockReturnValue(pool); const updateWhere = buildUpdateWhere(Users, true); - const updateData = { set: { username: '123' }, where: { username: 'foo' } }; + const updateWhereData: UpdateWhereData = { + set: { username: '123' }, + where: { username: 'foo' }, + jsonbMode: 'merge', + }; - await expect(updateWhere(updateData)).rejects.toMatchError(new UpdateError(Users, updateData)); + await expect(updateWhere(updateWhereData)).rejects.toMatchError( + new UpdateError(Users, updateWhereData) + ); }); }); diff --git a/packages/core/src/database/update-where.ts b/packages/core/src/database/update-where.ts index aff68d071..818bf1f90 100644 --- a/packages/core/src/database/update-where.ts +++ b/packages/core/src/database/update-where.ts @@ -29,14 +29,14 @@ export const buildUpdateWhere: BuildUpdateWhere = < ) => { const { table, fields } = convertToIdentifiers(schema); const isKeyOfSchema = isKeyOf(schema); - const connectKeyValueWithEqualSign = (data: Partial) => + const connectKeyValueWithEqualSign = (data: Partial, jsonbMode: 'replace' | 'merge') => Object.entries(data) .map(([key, value]) => { if (!isKeyOfSchema(key)) { return; } - if (value && typeof value === 'object' && !Array.isArray(value)) { + if (jsonbMode === 'merge' && value && typeof value === 'object' && !Array.isArray(value)) { /** * Jsonb || operator is used to shallow merge two jsonb types of data * all jsonb data field must be non-nullable @@ -52,17 +52,17 @@ export const buildUpdateWhere: BuildUpdateWhere = < }) .filter((value): value is Truthy => notFalsy(value)); - return async ({ set, where }: UpdateWhereData) => { + return async ({ set, where, jsonbMode }: UpdateWhereData) => { const { rows: [data], } = await envSet.pool.query(sql` update ${table} - set ${sql.join(connectKeyValueWithEqualSign(set), sql`, `)} - where ${sql.join(connectKeyValueWithEqualSign(where), sql` and `)} + set ${sql.join(connectKeyValueWithEqualSign(set, jsonbMode), sql`, `)} + where ${sql.join(connectKeyValueWithEqualSign(where, jsonbMode), sql` and `)} ${conditionalSql(returning, () => sql`returning *`)} `); - assertThat(!returning || data, new UpdateError(schema, { set, where })); + assertThat(!returning || data, new UpdateError(schema, { set, where, jsonbMode })); return data; }; diff --git a/packages/core/src/lib/passcode.ts b/packages/core/src/lib/passcode.ts index 5cca0eb03..aab9ee712 100644 --- a/packages/core/src/lib/passcode.ts +++ b/packages/core/src/lib/passcode.ts @@ -107,9 +107,13 @@ export const verifyPasscode = async ( if (code !== passcode.code) { // TODO use SQL's native +1 - await updatePasscode({ where: { id: passcode.id }, set: { tryCount: passcode.tryCount + 1 } }); + await updatePasscode({ + where: { id: passcode.id }, + set: { tryCount: passcode.tryCount + 1 }, + jsonbMode: 'merge', + }); throw new RequestError('passcode.code_mismatch'); } - await updatePasscode({ where: { id: passcode.id }, set: { consumed: true } }); + await updatePasscode({ where: { id: passcode.id }, set: { consumed: true }, jsonbMode: 'merge' }); }; diff --git a/packages/core/src/middleware/koa-slonik-error-handler.test.ts b/packages/core/src/middleware/koa-slonik-error-handler.test.ts index e276f9081..321a13efc 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.test.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.test.ts @@ -50,7 +50,11 @@ describe('koaSlonikErrorHandler middleware', () => { }); it('Update Error', async () => { - const error = new UpdateError(Users, { set: { name: 'punk' }, where: { id: '123' } }); + const error = new UpdateError(Users, { + set: { name: 'punk' }, + where: { id: '123' }, + jsonbMode: 'merge', + }); next.mockImplementationOnce(() => { throw error; }); diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index c26b65fc3..c29254790 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -43,8 +43,9 @@ const updateApplication = buildUpdateWhere(Appli export const updateApplicationById = async ( id: string, - set: Partial> -) => updateApplication({ set, where: { id } }); + set: Partial>, + jsonbMode: 'replace' | 'merge' = 'merge' +) => updateApplication({ set, where: { id }, jsonbMode }); export const deleteApplicationById = async (id: string) => { const { rowCount } = await envSet.pool.query(sql` diff --git a/packages/core/src/queries/connector.test.ts b/packages/core/src/queries/connector.test.ts index 25fa4689c..4fe20f2ac 100644 --- a/packages/core/src/queries/connector.test.ts +++ b/packages/core/src/queries/connector.test.ts @@ -80,7 +80,9 @@ describe('connector queries', () => { return createMockQueryResult([{ id, enabled }]); }); - await expect(updateConnector({ where: { id }, set: { enabled } })).resolves.toEqual({ + await expect( + updateConnector({ where: { id }, set: { enabled }, jsonbMode: 'merge' }) + ).resolves.toEqual({ id, enabled, }); diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index 9a232f74d..f598110f8 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -112,7 +112,9 @@ describe('passcode query', () => { return createMockQueryResult([{ ...mockPasscode, tryCount }]); }); - await expect(updatePasscode({ where: { id }, set: { tryCount } })).resolves.toEqual({ + await expect( + updatePasscode({ where: { id }, set: { tryCount }, jsonbMode: 'merge' }) + ).resolves.toEqual({ ...mockPasscode, tryCount, }); diff --git a/packages/core/src/queries/resource.ts b/packages/core/src/queries/resource.ts index f9d028078..4eb0195d8 100644 --- a/packages/core/src/queries/resource.ts +++ b/packages/core/src/queries/resource.ts @@ -49,8 +49,9 @@ const updateResource = buildUpdateWhere(Resources, tru export const updateResourceById = async ( id: string, - set: Partial> -) => updateResource({ set, where: { id } }); + set: Partial>, + jsonbMode: 'replace' | 'merge' = 'merge' +) => updateResource({ set, where: { id }, jsonbMode }); export const deleteResourceById = async (id: string) => { const { rowCount } = await envSet.pool.query(sql` diff --git a/packages/core/src/queries/setting.ts b/packages/core/src/queries/setting.ts index e410e8323..a8b902d0b 100644 --- a/packages/core/src/queries/setting.ts +++ b/packages/core/src/queries/setting.ts @@ -16,9 +16,12 @@ export const getSetting = async () => where ${fields.id}=${defaultSettingId} `); -export const updateSetting = async (setting: Partial>) => { +export const updateSetting = async ( + setting: Partial>, + jsonbMode: 'replace' | 'merge' = 'merge' +) => { return buildUpdateWhere( Settings, true - )({ set: setting, where: { id: defaultSettingId } }); + )({ set: setting, where: { id: defaultSettingId }, jsonbMode }); }; diff --git a/packages/core/src/queries/sign-in-experience.ts b/packages/core/src/queries/sign-in-experience.ts index 41f16147d..40a58a390 100644 --- a/packages/core/src/queries/sign-in-experience.ts +++ b/packages/core/src/queries/sign-in-experience.ts @@ -14,8 +14,10 @@ const updateSignInExperience = buildUpdateWhere) => - updateSignInExperience({ set, where: { id } }); +export const updateDefaultSignInExperience = async ( + set: Partial, + jsonbMode: 'replace' | 'merge' = 'merge' +) => updateSignInExperience({ set, where: { id }, jsonbMode }); export const findDefaultSignInExperience = async () => envSet.pool.one(sql` diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 1079d2d01..5ee09e8c6 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -114,8 +114,11 @@ export const findUsers = async (limit: number, offset: number, search?: string) const updateUser = buildUpdateWhere(Users, true); -export const updateUserById = async (id: string, set: Partial>) => - updateUser({ set, where: { id } }); +export const updateUserById = async ( + id: string, + set: Partial>, + jsonbMode: 'replace' | 'merge' = 'merge' +) => updateUser({ set, where: { id }, jsonbMode }); export const deleteUserById = async (id: string) => { const { rowCount } = await envSet.pool.query(sql` @@ -136,7 +139,7 @@ export const clearUserCustomDataById = async (id: string) => { `); if (rowCount < 1) { - throw new UpdateError(Users, { set: { customData: {} }, where: { id } }); + throw new UpdateError(Users, { set: { customData: {} }, where: { id }, jsonbMode: 'replace' }); } }; diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index de89ef420..ebaf15ac7 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -108,12 +108,16 @@ export default function connectorRoutes(router: T) { connector.metadata.type === metadata.type && connector.connector.enabled ) .map(async ({ connector: { id } }) => - updateConnector({ set: { enabled: false }, where: { id } }) + updateConnector({ set: { enabled: false }, where: { id }, jsonbMode: 'merge' }) ) ); } - const connector = await updateConnector({ set: { enabled }, where: { id } }); + const connector = await updateConnector({ + set: { enabled }, + where: { id }, + jsonbMode: 'merge', + }); ctx.body = { ...connector, metadata }; return next(); @@ -137,7 +141,7 @@ export default function connectorRoutes(router: T) { await validateConfig(body.config); } - const connector = await updateConnector({ set: body, where: { id } }); + const connector = await updateConnector({ set: body, where: { id }, jsonbMode: 'replace' }); ctx.body = { ...connector, metadata }; return next(); diff --git a/packages/core/src/routes/connector.update.test.ts b/packages/core/src/routes/connector.update.test.ts index 3e172f9fe..d20e62832 100644 --- a/packages/core/src/routes/connector.update.test.ts +++ b/packages/core/src/routes/connector.update.test.ts @@ -88,6 +88,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id' }, set: { enabled: true }, + jsonbMode: 'merge', }) ); expect(response.body).toMatchObject({ @@ -126,6 +127,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id' }, set: { enabled: false }, + jsonbMode: 'merge', }) ); expect(response.body).toMatchObject({ @@ -160,6 +162,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id1' }, set: { enabled: false }, + jsonbMode: 'merge', }) ); expect(updateConnector).toHaveBeenNthCalledWith( @@ -167,6 +170,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id5' }, set: { enabled: false }, + jsonbMode: 'merge', }) ); expect(updateConnector).toHaveBeenNthCalledWith( @@ -174,6 +178,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id1' }, set: { enabled: true }, + jsonbMode: 'merge', }) ); expect(response.body).toMatchObject({ @@ -214,6 +219,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id' }, set: { enabled: false }, + jsonbMode: 'merge', }) ); expect(response.body).toMatchObject({ @@ -270,6 +276,7 @@ describe('connector PATCH routes', () => { expect.objectContaining({ where: { id: 'id' }, set: { config: { cliend_id: 'client_id', client_secret: 'client_secret' } }, + jsonbMode: 'replace', }) ); expect(response.body).toMatchObject({