fix: support postgres material upserts without unique constraints
This commit is contained in:
@@ -110,6 +110,58 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
const manager = managerName?.trim() || null
|
const manager = managerName?.trim() || null
|
||||||
const dialect = this.getDialect()
|
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({
|
const { sql: sqlString } = dialect.upsert({
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyColumns: ['MaterialCode'],
|
keyColumns: ['MaterialCode'],
|
||||||
@@ -177,22 +229,12 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { sql: sqlString } = dialect.upsert({
|
const success = await this.upsertMaterial(materialCode, managerName)
|
||||||
table: tableName,
|
if (success) {
|
||||||
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++
|
stats.success++
|
||||||
|
} else {
|
||||||
|
stats.failed++
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error upserting material', {
|
log.error('Error upserting material', {
|
||||||
tableName,
|
tableName,
|
||||||
@@ -201,7 +243,6 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
materialCode,
|
materialCode,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
})
|
||||||
stats.failed++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,19 +279,8 @@ export class MaterialsToBeDeletedDAO {
|
|||||||
managerName: string
|
managerName: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const success = await this.upsertMaterial(materialCode, managerName)
|
||||||
const tableName = this.getTableName()
|
return { success }
|
||||||
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 }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Update manager error', {
|
log.error('Update manager error', {
|
||||||
materialCode,
|
materialCode,
|
||||||
|
|||||||
@@ -210,6 +210,58 @@ export class MaterialsTypeToBeDeletedDAO {
|
|||||||
const manager = managerName?.trim() || null
|
const manager = managerName?.trim() || null
|
||||||
const dialect = this.getDialect()
|
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({
|
const { sql: sqlString } = dialect.upsert({
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyColumns: ['MaterialName'],
|
keyColumns: ['MaterialName'],
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user