fix: support postgres material upserts without unique constraints

This commit is contained in:
Misaka
2026-04-14 21:55:05 +08:00
parent cb59dda727
commit 8fa4d6c16d
4 changed files with 288 additions and 30 deletions

View File

@@ -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 }
}
)
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,

View File

@@ -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'],

View File

@@ -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<unknown>) => ({ 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"')
})
})

View File

@@ -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<unknown>) => ({ 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"')
})
})