From 8fa4d6c16d35bff68b208d756c210acc07eaf8f8 Mon Sep 17 00:00:00 2001 From: Misaka Date: Tue, 14 Apr 2026 21:55:05 +0800 Subject: [PATCH] fix: support postgres material upserts without unique constraints --- .../database/materials-to-be-deleted-dao.ts | 90 ++++++++++++------- .../materials-type-to-be-deleted-dao.ts | 52 +++++++++++ .../materials-to-be-deleted-dao.test.ts | 88 ++++++++++++++++++ .../materials-type-to-be-deleted-dao.test.ts | 88 ++++++++++++++++++ 4 files changed, 288 insertions(+), 30 deletions(-) create mode 100644 tests/unit/services/database/materials-to-be-deleted-dao.test.ts create mode 100644 tests/unit/services/database/materials-type-to-be-deleted-dao.test.ts diff --git a/src/main/services/database/materials-to-be-deleted-dao.ts b/src/main/services/database/materials-to-be-deleted-dao.ts index c9ce34e..6b0cd52 100644 --- a/src/main/services/database/materials-to-be-deleted-dao.ts +++ b/src/main/services/database/materials-to-be-deleted-dao.ts @@ -110,6 +110,58 @@ export class MaterialsToBeDeletedDAO { const manager = managerName?.trim() || null const dialect = this.getDialect() + if (dbService.type === 'postgresql') { + const updateSql = ` + UPDATE ${tableName} + SET ManagerName = ${dialect.param(0)} + WHERE MaterialCode = ${dialect.param(1)} + ` + + const updateResult = await trackDuration( + async () => await dbService.query(updateSql, [manager, code]), + { + operationName: 'MaterialsToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' } + } + ) + + if (updateResult.result.rowCount > 0) { + return true + } + + const insertSql = ` + INSERT INTO ${tableName} (MaterialCode, ManagerName) + SELECT ${dialect.param(0)}, ${dialect.param(1)} + WHERE NOT EXISTS ( + SELECT 1 + FROM ${tableName} + WHERE MaterialCode = ${dialect.param(0)} + ) + ` + + const insertResult = await trackDuration( + async () => await dbService.query(insertSql, [code, manager]), + { + operationName: 'MaterialsToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' } + } + ) + + if (insertResult.result.rowCount > 0) { + return true + } + + const retryUpdateResult = await trackDuration( + async () => await dbService.query(updateSql, [manager, code]), + { + operationName: 'MaterialsToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' } + } + ) + + return retryUpdateResult.result.rowCount > 0 + } + const { sql: sqlString } = dialect.upsert({ table: tableName, keyColumns: ['MaterialCode'], @@ -177,22 +229,12 @@ export class MaterialsToBeDeletedDAO { } try { - const { sql: sqlString } = dialect.upsert({ - table: tableName, - keyColumns: ['MaterialCode'], - allColumns: ['MaterialCode', 'ManagerName'], - startParamIndex: 0 - }) - - await trackDuration( - async () => await dbService.query(sqlString, [materialCode, managerName || null]), - { - operationName: 'MaterialsToBeDeletedDAO.upsertBatch', - context: { tableName, operationType: 'UPSERT', batchId } - } - ) - - stats.success++ + const success = await this.upsertMaterial(materialCode, managerName) + if (success) { + stats.success++ + } else { + stats.failed++ + } } catch (error) { log.error('Error upserting material', { tableName, @@ -201,7 +243,6 @@ export class MaterialsToBeDeletedDAO { materialCode, error: error instanceof Error ? error.message : String(error) }) - stats.failed++ } } @@ -238,19 +279,8 @@ export class MaterialsToBeDeletedDAO { managerName: string ): Promise<{ success: boolean; error?: string }> { try { - const dbService = await this.getDatabaseService() - const tableName = this.getTableName() - const dialect = this.getDialect() - - const { sql: sqlString } = dialect.upsert({ - table: tableName, - keyColumns: ['MaterialCode'], - allColumns: ['MaterialCode', 'ManagerName'], - startParamIndex: 0 - }) - await dbService.query(sqlString, [materialCode, managerName || null]) - - return { success: true } + const success = await this.upsertMaterial(materialCode, managerName) + return { success } } catch (error) { log.error('Update manager error', { materialCode, diff --git a/src/main/services/database/materials-type-to-be-deleted-dao.ts b/src/main/services/database/materials-type-to-be-deleted-dao.ts index e48a5e0..9664885 100644 --- a/src/main/services/database/materials-type-to-be-deleted-dao.ts +++ b/src/main/services/database/materials-type-to-be-deleted-dao.ts @@ -210,6 +210,58 @@ export class MaterialsTypeToBeDeletedDAO { const manager = managerName?.trim() || null const dialect = this.getDialect() + if (dbService.type === 'postgresql') { + const updateSql = ` + UPDATE ${tableName} + SET ManagerName = ${dialect.param(0)} + WHERE MaterialName = ${dialect.param(1)} + ` + + const updateResult = await trackDuration( + async () => await dbService.query(updateSql, [manager, name]), + { + operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_UPDATE_FIRST' } + } + ) + + if (updateResult.result.rowCount > 0) { + return true + } + + const insertSql = ` + INSERT INTO ${tableName} (MaterialName, ManagerName) + SELECT ${dialect.param(0)}, ${dialect.param(1)} + WHERE NOT EXISTS ( + SELECT 1 + FROM ${tableName} + WHERE MaterialName = ${dialect.param(0)} + ) + ` + + const insertResult = await trackDuration( + async () => await dbService.query(insertSql, [name, manager]), + { + operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_INSERT_FALLBACK' } + } + ) + + if (insertResult.result.rowCount > 0) { + return true + } + + const retryUpdateResult = await trackDuration( + async () => await dbService.query(updateSql, [manager, name]), + { + operationName: 'MaterialsTypeToBeDeletedDAO.upsertMaterial', + context: { tableName, operationType: 'UPSERT_UPDATE_RETRY' } + } + ) + + return retryUpdateResult.result.rowCount > 0 + } + const { sql: sqlString } = dialect.upsert({ table: tableName, keyColumns: ['MaterialName'], diff --git a/tests/unit/services/database/materials-to-be-deleted-dao.test.ts b/tests/unit/services/database/materials-to-be-deleted-dao.test.ts new file mode 100644 index 0000000..f8d1068 --- /dev/null +++ b/tests/unit/services/database/materials-to-be-deleted-dao.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const queryMock = vi.fn() +const createMock = vi.fn() +const trackDurationMock = vi.fn(async (fn: () => Promise) => ({ result: await fn() })) + +vi.mock('../../../../src/main/services/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }), + getRequestId: () => 'test-request-id', + trackDuration: trackDurationMock +})) + +vi.mock('../../../../src/main/services/database/index', () => ({ + create: createMock +})) + +describe('MaterialsToBeDeletedDAO (PostgreSQL compatibility)', () => { + beforeEach(() => { + vi.clearAllMocks() + + createMock.mockResolvedValue({ + type: 'postgresql', + isConnected: () => true, + query: queryMock, + disconnect: vi.fn() + }) + }) + + it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => { + queryMock + .mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 0 + }) + .mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 1 + }) + + const { MaterialsToBeDeletedDAO } = await import( + '../../../../src/main/services/database/materials-to-be-deleted-dao' + ) + const dao = new MaterialsToBeDeletedDAO() + + const result = await dao.upsertMaterial('M-001', 'tester') + + expect(result).toBe(true) + expect(queryMock).toHaveBeenCalledTimes(2) + + const [updateSql, updateParams] = queryMock.mock.calls[0] + const [insertSql, insertParams] = queryMock.mock.calls[1] + + expect(updateSql).toContain('UPDATE "dbo"."MaterialsToBeDeleted"') + expect(updateSql).toContain('WHERE MaterialCode = $2') + expect(updateParams).toEqual(['tester', 'M-001']) + + expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsToBeDeleted" (MaterialCode, ManagerName)') + expect(insertSql).toContain('WHERE NOT EXISTS') + expect(insertSql).not.toContain('ON CONFLICT') + expect(insertParams).toEqual(['M-001', 'tester']) + }) + + it('reuses the PostgreSQL-safe path in updateManager', async () => { + queryMock.mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 1 + }) + + const { MaterialsToBeDeletedDAO } = await import( + '../../../../src/main/services/database/materials-to-be-deleted-dao' + ) + const dao = new MaterialsToBeDeletedDAO() + + const result = await dao.updateManager('M-001', 'tester') + + expect(result).toEqual({ success: true }) + expect(queryMock).toHaveBeenCalledTimes(1) + expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsToBeDeleted"') + }) +}) diff --git a/tests/unit/services/database/materials-type-to-be-deleted-dao.test.ts b/tests/unit/services/database/materials-type-to-be-deleted-dao.test.ts new file mode 100644 index 0000000..d97f686 --- /dev/null +++ b/tests/unit/services/database/materials-type-to-be-deleted-dao.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const queryMock = vi.fn() +const createMock = vi.fn() +const trackDurationMock = vi.fn(async (fn: () => Promise) => ({ result: await fn() })) + +vi.mock('../../../../src/main/services/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }), + getRequestId: () => 'test-request-id', + trackDuration: trackDurationMock +})) + +vi.mock('../../../../src/main/services/database/index', () => ({ + create: createMock +})) + +describe('MaterialsTypeToBeDeletedDAO (PostgreSQL compatibility)', () => { + beforeEach(() => { + vi.clearAllMocks() + + createMock.mockResolvedValue({ + type: 'postgresql', + isConnected: () => true, + query: queryMock, + disconnect: vi.fn() + }) + }) + + it('falls back to update-then-insert instead of ON CONFLICT for PostgreSQL inserts', async () => { + queryMock + .mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 0 + }) + .mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 1 + }) + + const { MaterialsTypeToBeDeletedDAO } = await import( + '../../../../src/main/services/database/materials-type-to-be-deleted-dao' + ) + const dao = new MaterialsTypeToBeDeletedDAO() + + const result = await dao.upsertMaterial('测试物料', 'tester') + + expect(result).toBe(true) + expect(queryMock).toHaveBeenCalledTimes(2) + + const [updateSql, updateParams] = queryMock.mock.calls[0] + const [insertSql, insertParams] = queryMock.mock.calls[1] + + expect(updateSql).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"') + expect(updateSql).toContain('WHERE MaterialName = $2') + expect(updateParams).toEqual(['tester', '测试物料']) + + expect(insertSql).toContain('INSERT INTO "dbo"."MaterialsTypeToBeDeleted" (MaterialName, ManagerName)') + expect(insertSql).toContain('WHERE NOT EXISTS') + expect(insertSql).not.toContain('ON CONFLICT') + expect(insertParams).toEqual(['测试物料', 'tester']) + }) + + it('returns after the first update when the material already exists', async () => { + queryMock.mockResolvedValueOnce({ + rows: [], + columns: [], + rowCount: 1 + }) + + const { MaterialsTypeToBeDeletedDAO } = await import( + '../../../../src/main/services/database/materials-type-to-be-deleted-dao' + ) + const dao = new MaterialsTypeToBeDeletedDAO() + + const result = await dao.upsertMaterial('测试物料', 'tester') + + expect(result).toBe(true) + expect(queryMock).toHaveBeenCalledTimes(1) + expect((queryMock.mock.calls[0][0] as string)).toContain('UPDATE "dbo"."MaterialsTypeToBeDeleted"') + }) +})