Files
BIPMaterialManager/docs/plans/2026-04-05-postgresql-integration-plan.md
Misaka 6c730616b8 docs: add PostgreSQL integration implementation plan
6-task TDD plan covering SqlDialect abstraction, PostgreSqlService,
DAO refactoring, and config/factory integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 09:14:33 +08:00

36 KiB

PostgreSQL Integration Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Integrate PostgreSQL as a third database option by introducing a SqlDialect abstraction layer that unifies SQL dialect differences across MySQL, SQL Server, and PostgreSQL.

Architecture: Create a SqlDialect interface with implementations for each database. Refactor all 4 DAOs to delegate dialect-specific logic (placeholders, table names, UPSERT, pagination, timestamps) to their dialect object. Add a PostgreSqlService implementing IDatabaseService using the pg driver. Extend config schemas and factory to support the new database type.

Tech Stack: TypeScript, pg (node-postgres), TypeORM, Zod, Vitest


Task 1: SqlDialect Interface & Type Definitions

Files:

  • Create: src/main/types/sql-dialect.types.ts
  • Modify: src/main/types/database.types.ts:11 (extend DatabaseType)
  • Modify: src/main/types/config.schema.ts:15 (extend Zod enum)

Step 1: Add 'postgresql' to DatabaseType

In src/main/types/database.types.ts:11, change:

export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql'

Add after line 104 (after SqlServerConfig):

/**
 * PostgreSQL-specific configuration
 */
export interface PostgreSqlConfig extends DatabaseConfig {
  host: string
  port: number
  user: string
  password: string
  database: string
  maxPoolSize?: number
}

Step 2: Add PostgreSQL to Zod config schema

In src/main/types/config.schema.ts:

Change line 15:

export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql'])

Add after sqlServerConfigSchema (after line 58):

/**
 * PostgreSQL 配置 Schema
 */
export const postgresqlConfigSchema = z.object({
  host: z.string().min(1, 'PostgreSQL host is required'),
  port: z.number().int().min(1).max(65535).default(5432),
  database: z.string().min(1, 'PostgreSQL database is required'),
  username: z.string().min(1, 'PostgreSQL username is required'),
  password: z.string(),
  maxPoolSize: z.number().int().min(1).max(100).default(10)
})
export type PostgreSqlConfig = z.infer<typeof postgresqlConfigSchema>

Change databaseConfigSchema (line 63):

export const databaseConfigSchema = z.object({
  activeType: databaseTypeSchema.default('mysql'),
  mysql: mysqlConfigSchema,
  sqlserver: sqlServerConfigSchema,
  postgresql: postgresqlConfigSchema
})

Step 3: Create SqlDialect interface

Create src/main/types/sql-dialect.types.ts:

/**
 * SQL Dialect Abstraction
 *
 * Provides a unified interface for database-specific SQL syntax differences.
 * Each database type implements this interface to encapsulate:
 * - Parameter placeholder format
 * - Table name quoting
 * - UPSERT syntax
 * - Pagination syntax
 * - Current timestamp function
 * - Batch size limits
 */

import type { DatabaseType } from './database.types'

export interface SqlDialect {
  /** Database type identifier */
  readonly dbType: DatabaseType

  /**
   * Quote a table name with schema prefix
   * MySQL: dbo_TableName
   * SQL Server: [dbo].[TableName]
   * PostgreSQL: "dbo"."TableName"
   */
  quoteTableName(schema: string, table: string): string

  /**
   * Get placeholder for parameter at given index (0-based)
   * MySQL: ?
   * SQL Server: @p0
   * PostgreSQL: $1
   */
  param(index: number): string

  /**
   * Get comma-separated placeholders for count parameters
   */
  params(count: number): string

  /**
   * Get current timestamp SQL function
   * MySQL: NOW()
   * SQL Server: GETDATE()
   * PostgreSQL: CURRENT_TIMESTAMP
   */
  currentTimestamp(): string

  /**
   * Generate UPSERT SQL for a single row
   * MySQL: INSERT ... ON DUPLICATE KEY UPDATE
   * SQL Server: MERGE ... USING ...
   * PostgreSQL: INSERT ... ON CONFLICT ... DO UPDATE SET
   *
   * @param table - Quoted table name
   * @param keyColumns - Columns that identify the unique key
   * @param allColumns - All columns to insert/update
   * @param startParamIndex - Starting parameter index (0-based)
   * @returns Object with sql string and next param index
   */
  upsert(params: {
    table: string
    keyColumns: string[]
    allColumns: string[]
    startParamIndex: number
  }): { sql: string; nextParamIndex: number }

  /**
   * Append pagination clause to SQL
   * MySQL/PostgreSQL: LIMIT x OFFSET y
   * SQL Server: OFFSET x ROWS FETCH NEXT y ROWS ONLY
   *
   * @returns Object with modified sql and next param index
   */
  paginate(params: {
    sql: string
    limit: number
    offset?: number
    paramIndex: number
  }): { sql: string; nextParamIndex: number }

  /**
   * Maximum rows per batch given columns per row
   * SQL Server: ~71 (due to 2100 param limit)
   * MySQL/PostgreSQL: 1000
   */
  maxBatchRows(columnsPerRow: number): number
}

Step 4: Run typecheck to verify no breakage

Run: npx vitest typecheck or npm run typecheck Expected: May have errors in config-manager.ts due to new required postgresql field in schema — this is expected and will be fixed in Task 6.

Step 5: Commit

git add src/main/types/sql-dialect.types.ts src/main/types/database.types.ts src/main/types/config.schema.ts
git commit -m "feat(db): add SqlDialect interface and PostgreSQL type definitions"

Task 2: Implement Three Dialect Classes

Files:

  • Create: src/main/services/database/dialects/mysql-dialect.ts
  • Create: src/main/services/database/dialects/sqlserver-dialect.ts
  • Create: src/main/services/database/dialects/postgresql-dialect.ts
  • Create: src/main/services/database/dialects/index.ts
  • Test: tests/unit/dialects/mysql-dialect.test.ts
  • Test: tests/unit/dialects/sqlserver-dialect.test.ts
  • Test: tests/unit/dialects/postgresql-dialect.test.ts

Step 1: Write tests for all three dialects

Create tests/unit/dialects/mysql-dialect.test.ts:

import { describe, it, expect } from 'vitest'
import { MySqlDialect } from '@main/services/database/dialects/mysql-dialect'

describe('MySqlDialect', () => {
  const dialect = new MySqlDialect()

  it('should have dbType mysql', () => {
    expect(dialect.dbType).toBe('mysql')
  })

  it('quoteTableName should use underscore notation', () => {
    expect(dialect.quoteTableName('dbo', 'TableName')).toBe('dbo_TableName')
  })

  it('param should return ? for any index', () => {
    expect(dialect.param(0)).toBe('?')
    expect(dialect.param(5)).toBe('?')
  })

  it('params should return comma-separated question marks', () => {
    expect(dialect.params(3)).toBe('?,?,?')
    expect(dialect.params(1)).toBe('?')
  })

  it('currentTimestamp should return NOW()', () => {
    expect(dialect.currentTimestamp()).toBe('NOW()')
  })

  it('upsert should generate ON DUPLICATE KEY UPDATE', () => {
    const result = dialect.upsert({
      table: 'dbo_MaterialsToBeDeleted',
      keyColumns: ['MaterialCode'],
      allColumns: ['MaterialCode', 'ManagerName'],
      startParamIndex: 0
    })
    expect(result.sql).toContain('INSERT INTO dbo_MaterialsToBeDeleted')
    expect(result.sql).toContain('ON DUPLICATE KEY UPDATE')
    expect(result.nextParamIndex).toBe(2)
  })

  it('paginate should append LIMIT OFFSET', () => {
    const result = dialect.paginate({
      sql: 'SELECT * FROM t ORDER BY id',
      limit: 10,
      offset: 20,
      paramIndex: 5
    })
    expect(result.sql).toContain('LIMIT 10 OFFSET 20')
    expect(result.nextParamIndex).toBe(5)
  })

  it('maxBatchRows should return 1000', () => {
    expect(dialect.maxBatchRows(28)).toBe(1000)
  })
})

Create tests/unit/dialects/sqlserver-dialect.test.ts:

import { describe, it, expect } from 'vitest'
import { SqlServerDialect } from '@main/services/database/dialects/sqlserver-dialect'

describe('SqlServerDialect', () => {
  const dialect = new SqlServerDialect()

  it('should have dbType sqlserver', () => {
    expect(dialect.dbType).toBe('sqlserver')
  })

  it('quoteTableName should use bracket notation', () => {
    expect(dialect.quoteTableName('dbo', 'TableName')).toBe('[dbo].[TableName]')
  })

  it('param should return @pN', () => {
    expect(dialect.param(0)).toBe('@p0')
    expect(dialect.param(3)).toBe('@p3')
  })

  it('params should return comma-separated @pN', () => {
    expect(dialect.params(3)).toBe('@p0,@p1,@p2')
  })

  it('currentTimestamp should return GETDATE()', () => {
    expect(dialect.currentTimestamp()).toBe('GETDATE()')
  })

  it('upsert should generate MERGE statement', () => {
    const result = dialect.upsert({
      table: '[dbo].[MaterialsToBeDeleted]',
      keyColumns: ['MaterialCode'],
      allColumns: ['MaterialCode', 'ManagerName'],
      startParamIndex: 0
    })
    expect(result.sql).toContain('MERGE')
    expect(result.sql).toContain('WHEN MATCHED THEN UPDATE')
    expect(result.sql).toContain('WHEN NOT MATCHED THEN INSERT')
    expect(result.nextParamIndex).toBe(2)
  })

  it('paginate should append OFFSET/FETCH with param placeholders', () => {
    const result = dialect.paginate({
      sql: 'SELECT * FROM t ORDER BY id',
      limit: 10,
      offset: 20,
      paramIndex: 5
    })
    expect(result.sql).toContain('OFFSET @p5 ROWS')
    expect(result.sql).toContain('FETCH NEXT @p6 ROWS ONLY')
    expect(result.nextParamIndex).toBe(7)
  })

  it('paginate without offset should use OFFSET 0', () => {
    const result = dialect.paginate({
      sql: 'SELECT * FROM t ORDER BY id',
      limit: 10,
      paramIndex: 5
    })
    expect(result.sql).toContain('OFFSET 0 ROWS')
    expect(result.sql).toContain('FETCH NEXT @p5 ROWS ONLY')
    expect(result.nextParamIndex).toBe(6)
  })

  it('maxBatchRows should respect 2100 param limit', () => {
    expect(dialect.maxBatchRows(28)).toBe(Math.floor(2000 / 28))
    expect(dialect.maxBatchRows(28)).toBeLessThanOrEqual(75)
  })
})

Create tests/unit/dialects/postgresql-dialect.test.ts:

import { describe, it, expect } from 'vitest'
import { PostgreSqlDialect } from '@main/services/database/dialects/postgresql-dialect'

describe('PostgreSqlDialect', () => {
  const dialect = new PostgreSqlDialect()

  it('should have dbType postgresql', () => {
    expect(dialect.dbType).toBe('postgresql')
  })

  it('quoteTableName should use double-quote notation', () => {
    expect(dialect.quoteTableName('dbo', 'TableName')).toBe('"dbo"."TableName"')
  })

  it('param should return $N (1-based)', () => {
    expect(dialect.param(0)).toBe('$1')
    expect(dialect.param(3)).toBe('$4')
  })

  it('params should return comma-separated $N', () => {
    expect(dialect.params(3)).toBe('$1,$2,$3')
  })

  it('currentTimestamp should return CURRENT_TIMESTAMP', () => {
    expect(dialect.currentTimestamp()).toBe('CURRENT_TIMESTAMP')
  })

  it('upsert should generate ON CONFLICT DO UPDATE', () => {
    const result = dialect.upsert({
      table: '"dbo"."MaterialsToBeDeleted"',
      keyColumns: ['MaterialCode'],
      allColumns: ['MaterialCode', 'ManagerName'],
      startParamIndex: 0
    })
    expect(result.sql).toContain('INSERT INTO "dbo"."MaterialsToBeDeleted"')
    expect(result.sql).toContain('ON CONFLICT ("MaterialCode")')
    expect(result.sql).toContain('DO UPDATE SET')
    expect(result.nextParamIndex).toBe(2)
  })

  it('paginate should append LIMIT OFFSET (literal, not param)', () => {
    const result = dialect.paginate({
      sql: 'SELECT * FROM t ORDER BY id',
      limit: 10,
      offset: 20,
      paramIndex: 5
    })
    expect(result.sql).toContain('LIMIT 10 OFFSET 20')
    expect(result.nextParamIndex).toBe(5)
  })

  it('maxBatchRows should return 1000', () => {
    expect(dialect.maxBatchRows(28)).toBe(1000)
  })
})

Step 2: Run tests to verify they fail

Run: npx vitest run tests/unit/dialects/ Expected: FAIL — modules not found

Step 3: Implement MySqlDialect

Create src/main/services/database/dialects/mysql-dialect.ts:

import type { SqlDialect } from '../../../types/sql-dialect.types'
import type { DatabaseType } from '../../../types/database.types'

export class MySqlDialect implements SqlDialect {
  readonly dbType: DatabaseType = 'mysql'

  quoteTableName(schema: string, table: string): string {
    return `${schema}_${table}`
  }

  param(_index: number): string {
    return '?'
  }

  params(count: number): string {
    return Array.from({ length: count }, () => '?').join(',')
  }

  currentTimestamp(): string {
    return 'NOW()'
  }

  upsert(p: {
    table: string
    keyColumns: string[]
    allColumns: string[]
    startParamIndex: number
  }): { sql: string; nextParamIndex: number } {
    const placeholders = this.params(p.allColumns.length)
    const columns = p.allColumns.join(', ')
    const updateSet = p.allColumns
      .filter((col) => !p.keyColumns.includes(col))
      .map((col) => `${col} = VALUES(${col})`)
      .join(', ')

    const sql = `
      INSERT INTO ${p.table} (${columns})
      VALUES (${placeholders})
      ON DUPLICATE KEY UPDATE ${updateSet}
    `
    return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
  }

  paginate(p: {
    sql: string
    limit: number
    offset?: number
    paramIndex: number
  }): { sql: string; nextParamIndex: number } {
    let sql = p.sql
    if (p.offset !== undefined) {
      sql += ` LIMIT ${p.limit} OFFSET ${p.offset}`
    } else {
      sql += ` LIMIT ${p.limit}`
    }
    return { sql, nextParamIndex: p.paramIndex }
  }

  maxBatchRows(_columnsPerRow: number): number {
    return 1000
  }
}

Step 4: Implement SqlServerDialect

Create src/main/services/database/dialects/sqlserver-dialect.ts:

import type { SqlDialect } from '../../../types/sql-dialect.types'
import type { DatabaseType } from '../../../types/database.types'

export class SqlServerDialect implements SqlDialect {
  readonly dbType: DatabaseType = 'sqlserver'

  quoteTableName(schema: string, table: string): string {
    return `[${schema}].[${table}]`
  }

  param(index: number): string {
    return `@p${index}`
  }

  params(count: number): string {
    return Array.from({ length: count }, (_, i) => `@p${i}`).join(',')
  }

  currentTimestamp(): string {
    return 'GETDATE()'
  }

  upsert(p: {
    table: string
    keyColumns: string[]
    allColumns: string[]
    startParamIndex: number
  }): { sql: string; nextParamIndex: number } {
    const sourceValues = p.allColumns
      .map((_, i) => `@p${p.startParamIndex + i}`)
      .join(', ')
    const sourceColumns = p.allColumns.join(', ')
    const keyMatch = p.keyColumns
      .map((col) => `target.${col} = source.${col}`)
      .join(' AND ')
    const updateSet = p.allColumns
      .filter((col) => !p.keyColumns.includes(col))
      .map((col) => `${col} = source.${col}`)
      .join(', ')
    const insertColumns = p.allColumns.join(', ')
    const insertValues = p.allColumns
      .map((col) => `source.${col}`)
      .join(', ')

    const sql = `
      MERGE ${p.table} AS target
      USING (VALUES (${sourceValues})) AS source (${sourceColumns})
      ON ${keyMatch}
      WHEN MATCHED THEN UPDATE SET ${updateSet}
      WHEN NOT MATCHED THEN INSERT (${insertColumns}) VALUES (${insertValues});
    `
    return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
  }

  paginate(p: {
    sql: string
    limit: number
    offset?: number
    paramIndex: number
  }): { sql: string; nextParamIndex: number } {
    let sql = p.sql
    let nextIndex = p.paramIndex

    if (p.offset !== undefined) {
      sql += ` OFFSET @p${nextIndex} ROWS FETCH NEXT @p${nextIndex + 1} ROWS ONLY`
      nextIndex += 2
    } else {
      sql += ` OFFSET 0 ROWS FETCH NEXT @p${nextIndex} ROWS ONLY`
      nextIndex += 1
    }
    return { sql, nextParamIndex: nextIndex }
  }

  maxBatchRows(columnsPerRow: number): number {
    // SQL Server has a limit of ~2100 parameters per query
    // Leave margin for query overhead
    const maxParams = 2000
    return Math.floor(maxParams / columnsPerRow)
  }
}

Step 5: Implement PostgreSqlDialect

Create src/main/services/database/dialects/postgresql-dialect.ts:

import type { SqlDialect } from '../../../types/sql-dialect.types'
import type { DatabaseType } from '../../../types/database.types'

export class PostgreSqlDialect implements SqlDialect {
  readonly dbType: DatabaseType = 'postgresql'

  quoteTableName(schema: string, table: string): string {
    return `"${schema}"."${table}"`
  }

  param(index: number): string {
    // PostgreSQL uses 1-based parameter placeholders
    return `$${index + 1}`
  }

  params(count: number): string {
    return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(',')
  }

  currentTimestamp(): string {
    return 'CURRENT_TIMESTAMP'
  }

  upsert(p: {
    table: string
    keyColumns: string[]
    allColumns: string[]
    startParamIndex: number
  }): { sql: string; nextParamIndex: number } {
    const placeholders = p.allColumns
      .map((_, i) => `$${p.startParamIndex + i + 1}`)
      .join(', ')
    const columns = p.allColumns.join(', ')
    const conflictKeys = p.keyColumns.map((k) => `"${k}"`).join(', ')
    const updateSet = p.allColumns
      .filter((col) => !p.keyColumns.includes(col))
      .map((col) => `"${col}" = EXCLUDED."${col}"`)
      .join(', ')

    const sql = `
      INSERT INTO ${p.table} (${columns})
      VALUES (${placeholders})
      ON CONFLICT (${conflictKeys}) DO UPDATE SET ${updateSet}
    `
    return { sql, nextParamIndex: p.startParamIndex + p.allColumns.length }
  }

  paginate(p: {
    sql: string
    limit: number
    offset?: number
    paramIndex: number
  }): { sql: string; nextParamIndex: number } {
    let sql = p.sql
    if (p.offset !== undefined) {
      sql += ` LIMIT ${p.limit} OFFSET ${p.offset}`
    } else {
      sql += ` LIMIT ${p.limit}`
    }
    return { sql, nextParamIndex: p.paramIndex }
  }

  maxBatchRows(_columnsPerRow: number): number {
    return 1000
  }
}

Step 6: Create dialect factory index

Create src/main/services/database/dialects/index.ts:

export { MySqlDialect } from './mysql-dialect'
export { SqlServerDialect } from './sqlserver-dialect'
export { PostgreSqlDialect } from './postgresql-dialect'

import type { SqlDialect } from '../../../types/sql-dialect.types'
import type { DatabaseType } from '../../../types/database.types'
import { MySqlDialect } from './mysql-dialect'
import { SqlServerDialect } from './sqlserver-dialect'
import { PostgreSqlDialect } from './postgresql-dialect'

export function createDialect(type: DatabaseType): SqlDialect {
  switch (type) {
    case 'sqlserver':
      return new SqlServerDialect()
    case 'postgresql':
      return new PostgreSqlDialect()
    default:
      return new MySqlDialect()
  }
}

Step 7: Run tests to verify they pass

Run: npx vitest run tests/unit/dialects/ Expected: ALL PASS

Step 8: Commit

git add src/main/services/database/dialects/ tests/unit/dialects/
git commit -m "feat(db): implement SqlDialect abstraction with MySQL, SQL Server, PostgreSQL dialects"

Task 3: PostgreSqlService Implementation

Files:

  • Create: src/main/services/database/postgresql.ts
  • Test: tests/unit/postgresql.test.ts

Step 1: Write tests

Create tests/unit/postgresql.test.ts:

import { describe, it, expect, beforeEach } from 'vitest'
import { PostgreSqlService } from '@main/services/database/postgresql'

const mockConfig = {
  host: 'localhost',
  port: 5432,
  user: 'test',
  password: 'test',
  database: 'testdb'
}

describe('PostgreSqlService Unit Tests', () => {
  let service: PostgreSqlService

  beforeEach(() => {
    service = new PostgreSqlService(mockConfig)
  })

  describe('constructor', () => {
    it('should create service with config', () => {
      expect(service).toBeDefined()
      expect(service.isConnected()).toBe(false)
    })
  })

  describe('type', () => {
    it('should return postgresql', () => {
      expect(service.type).toBe('postgresql')
    })
  })

  describe('isConnected', () => {
    it('should return false when not connected', () => {
      expect(service.isConnected()).toBe(false)
    })
  })

  describe('query', () => {
    it('should throw error when not connected', async () => {
      await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to PostgreSQL')
    })
  })

  describe('transaction', () => {
    it('should throw error when not connected', async () => {
      await expect(service.transaction([{ sql: 'SELECT 1' }])).rejects.toThrow(
        'Not connected to PostgreSQL'
      )
    })
  })

  describe('connect', () => {
    it('should throw error with invalid host', async () => {
      const invalidConfig = {
        ...mockConfig,
        host: 'invalid-host-that-does-not-exist'
      }
      const invalidService = new PostgreSqlService(invalidConfig)

      await expect(invalidService.connect()).rejects.toThrow('Failed to connect to PostgreSQL')
    })
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run tests/unit/postgresql.test.ts Expected: FAIL — module not found

Step 3: Install pg dependency

Run: npm install pg && npm install -D @types/pg

Step 4: Implement PostgreSqlService

Create src/main/services/database/postgresql.ts:

import { Pool } from 'pg'
import type {
  IDatabaseService,
  DatabaseType,
  QueryResult,
  PostgreSqlConfig
} from '../../types/database.types'
import { createLogger, trackDuration } from '../logger'

const log = createLogger('PostgreSqlService')

export class PostgreSqlService implements IDatabaseService {
  readonly type: DatabaseType = 'postgresql'

  private pool: Pool | null = null
  private config: PostgreSqlConfig

  constructor(config: PostgreSqlConfig) {
    this.config = config
  }

  async connect(): Promise<void> {
    if (this.pool) {
      log.warn('Already connected to PostgreSQL')
      throw new Error('Already connected to PostgreSQL')
    }

    try {
      this.pool = new Pool({
        host: this.config.host,
        port: this.config.port,
        user: this.config.user,
        password: this.config.password,
        database: this.config.database,
        max: this.config.maxPoolSize ?? 10
      })

      // Test connection
      const client = await this.pool.connect()
      client.release()

      log.info('Connected to PostgreSQL', {
        host: this.config.host,
        port: this.config.port,
        database: this.config.database
      })
    } catch (error) {
      this.pool = null
      log.error('Failed to connect to PostgreSQL', {
        host: this.config.host,
        port: this.config.port,
        database: this.config.database,
        error
      })
      throw new Error(`Failed to connect to PostgreSQL: ${(error as Error).message}`)
    }
  }

  async disconnect(): Promise<void> {
    if (!this.pool) {
      return
    }

    try {
      await this.pool.end()
      this.pool = null
      log.info('Disconnected from PostgreSQL')
    } catch (error) {
      log.error('Failed to disconnect from PostgreSQL', { error })
      throw new Error(`Failed to disconnect from PostgreSQL: ${(error as Error).message}`)
    }
  }

  isConnected(): boolean {
    return this.pool !== null
  }

  async query(sql: string, params?: any[]): Promise<QueryResult> {
    if (!this.pool) {
      throw new Error('Not connected to PostgreSQL. Call connect() first.')
    }

    const sqlPreview = sql.substring(0, 100)
    const paramCount = params?.length ?? 0

    try {
      const { result: queryResult } = await trackDuration(
        async () => {
          const result = await this.pool!.query(sql, params)

          const rows = result.rows as Record<string, unknown>[]
          const columns = result.fields.map((f) => f.name)

          return {
            rows,
            columns,
            rowCount: result.rowCount ?? rows.length
          }
        },
        { operationName: 'PostgreSqlService.query' }
      )

      log.debug('Query executed', { sqlPreview, rowCount: queryResult.rowCount, paramCount })
      return queryResult
    } catch (error) {
      log.error('PostgreSQL query failed', { sqlPreview, paramCount, error })
      throw new Error(`PostgreSQL query failed: ${(error as Error).message}`)
    }
  }

  async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
    if (!this.pool) {
      throw new Error('Not connected to PostgreSQL. Call connect() first.')
    }

    const queryCount = queries.length
    const client = await this.pool.connect()
    log.info('Transaction started', { queryCount })

    try {
      await client.query('BEGIN')

      for (let i = 0; i < queries.length; i++) {
        const { sql, params } = queries[i]
        await client.query(sql, params)
        log.debug('Transaction query executed', { index: i, sqlPreview: sql.substring(0, 100) })
      }

      await client.query('COMMIT')
      log.info('Transaction committed', { queryCount })
    } catch (error) {
      await client.query('ROLLBACK')
      log.warn('Transaction rolled back', { queryCount, error })
      throw new Error(`PostgreSQL transaction failed: ${(error as Error).message}`)
    } finally {
      client.release()
    }
  }
}

Step 5: Run tests to verify they pass

Run: npx vitest run tests/unit/postgresql.test.ts Expected: ALL PASS

Step 6: Commit

git add src/main/services/database/postgresql.ts tests/unit/postgresql.test.ts package.json package-lock.json
git commit -m "feat(db): add PostgreSqlService with pg driver"

Task 4: Refactor DiscreteMaterialPlanDAO

Files:

  • Modify: src/main/services/database/discrete-material-plan-dao.ts
  • Modify: src/main/services/database/materials-to-be-deleted-dao.ts
  • Modify: src/main/services/database/materials-type-to-be-deleted-dao.ts
  • Modify: src/main/services/database/extractor-operation-history-dao.ts

This is the largest task. Each DAO follows the same pattern — replace isSqlServer checks with dialect calls.

DiscreteMaterialPlanDAO changes:

Step 1: Add dialect import and member

At top of file, add import:

import { createDialect, type SqlDialect } from './dialects'

In class body, add:

private dialect: SqlDialect | null = null

private getDialect(): SqlDialect {
  if (!this.dialect) {
    this.dialect = createDialect(this.dbService!.type)
  }
  return this.dialect
}

Step 2: Remove getTableName() and buildPlaceholders() methods

Replace with:

private getTableName(): string {
  return this.getDialect().quoteTableName('dbo', 'DiscreteMaterialPlanData')
}

Delete buildPlaceholders() entirely — replaced by dialect.params().

Step 3: Replace all isSqlServer local variables

Replace patterns like:

const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'

With:

const dialect = this.getDialect()
const placeholder = dialect.param(0)

Replace this.buildPlaceholders(batch.length, isSqlServer) with dialect.params(batch.length).

Replace isSqlServer ? '@p0' : '?' single placeholders with dialect.param(0).

Step 4: Refactor buildRowValues()

Change from:

if (isSqlServer) {
  return `@p${values.length - 1}`
} else {
  return '?'
}

To:

return this.getDialect().param(values.length - 1)

Step 5: Refactor batchInsert() batch size

Change from:

const isSqlServer = dbService.type === 'sqlserver'
const effectiveBatchSize = isSqlServer
  ? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow))
  : batchSize

To:

const dialect = this.getDialect()
const effectiveBatchSize = Math.min(batchSize, dialect.maxBatchRows(columnsPerRow))

MaterialsToBeDeletedDAO changes:

Same pattern as above, plus refactor UPSERT methods:

Step 6: Replace MERGE/ON DUPLICATE KEY with dialect.upsert()

In upsertMaterial(), replace the entire if/else block:

if (isSqlServer) {
  // MERGE ...
} else {
  // ON DUPLICATE KEY UPDATE ...
}

With:

const dialect = this.getDialect()
const { sql: sqlString } = dialect.upsert({
  table: tableName,
  keyColumns: ['MaterialCode'],
  allColumns: ['MaterialCode', 'ManagerName'],
  startParamIndex: 0
})
await trackDuration(async () => await dbService.query(sqlString, [code, manager]), {
  operationName: 'MaterialsToBeDeletedDAO.upsertMaterial',
  context: { tableName, operationType: 'UPSERT' }
})

Same for upsertBatch() — each iteration calls dialect.upsert().

MaterialsTypeToBeDeletedDAO changes:

Step 7: Same pattern as MaterialsToBeDeletedDAO

Replace getTableName(), buildPlaceholders(), isSqlServer checks, and upsertMaterial() UPSERT logic.

Additionally, refactor updateMaterial() — the entire if/else block that duplicates SQL just for placeholder differences becomes one SQL string using dialect.param().

ExtractorOperationHistoryDAO changes:

Step 8: Replace getTableName(), buildPlaceholders(), isSqlServer checks

Plus specific changes:

In insertBatchRecords(), replace the if/else with:

const dialect = this.getDialect()
const sqlString = `
  INSERT INTO ${tableName}
    (BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
  VALUES
    (${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.currentTimestamp()}, 'pending')
`

In getBatches(), replace pagination logic with:

if (options?.limit) {
  const result = dialect.paginate({
    sql: sqlString,
    limit: safeLimit,
    offset: safeOffset,
    paramIndex: params.length
  })
  sqlString = result.sql
  // Note: SQL Server's paginate adds params to the params array; PostgreSQL/MySQL don't
}

Step 9: Run existing tests to verify no regressions

Run: npx vitest run Expected: ALL PASS (existing tests should still pass since dialects produce same SQL for MySQL/SQL Server)

Step 10: Commit

git add src/main/services/database/discrete-material-plan-dao.ts src/main/services/database/materials-to-be-deleted-dao.ts src/main/services/database/materials-type-to-be-deleted-dao.ts src/main/services/database/extractor-operation-history-dao.ts
git commit -m "refactor(db): replace isSqlServer checks with SqlDialect abstraction in all DAOs"

Task 5: Database Factory & Config Integration

Files:

  • Modify: src/main/services/database/index.ts
  • Modify: src/main/services/config/config-manager.ts:47-69,302-308
  • Modify: src/main/services/database/data-source.ts
  • Modify: config.template.yaml

Step 1: Update database factory

In src/main/services/database/index.ts:

Add imports:

import { PostgreSqlService } from './postgresql'
import type { PostgreSqlConfig } from '../../types/database.types'

Add config factory:

export function createPostgreSqlConfig(): PostgreSqlConfig {
  const configManager = ConfigManager.getInstance()
  const dbConfig = configManager.getConfig().database.postgresql
  return {
    host: dbConfig.host,
    port: dbConfig.port,
    user: dbConfig.username,
    password: dbConfig.password,
    database: dbConfig.database,
    maxPoolSize: dbConfig.maxPoolSize
  }
}

Update create() function (line 89):

if (dbType === 'postgresql') {
  log.info('Creating PostgreSQL database service')
  service = new PostgreSqlService(createPostgreSqlConfig())
} else if (dbType === 'sqlserver') {
  log.info('Creating SQL Server database service')
  service = new SqlServerService(createSqlServerConfig())
} else {
  log.info('Creating MySQL database service')
  service = new MySqlService(createMySqlConfig())
}

Add to re-exports:

export { PostgreSqlService } from './postgresql'

Step 2: Update ConfigManager defaults

In src/main/services/config/config-manager.ts, add to DEFAULT_CONFIG.database (after line 69):

postgresql: {
  host: 'localhost',
  port: 5432,
  database: 'erp_db',
  username: 'postgres',
  password: '',
  maxPoolSize: 10
}

Update getActiveDatabaseConfig() (line 302-308):

public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConfig {
  const { activeType, mysql, sqlserver, postgresql } = this.config.database
  switch (activeType) {
    case 'postgresql': return postgresql
    case 'sqlserver': return sqlserver
    default: return mysql
  }
}

Step 3: Update TypeORM data-source

In src/main/services/database/data-source.ts:

Update getDatabaseType():

function getDatabaseType(): 'mysql' | 'mssql' | 'postgres' {
  const configManager = ConfigManager.getInstance()
  const dbType = configManager.getDatabaseType()
  switch (dbType) {
    case 'sqlserver': return 'mssql'
    case 'postgresql': return 'postgres'
    default: return 'mysql'
  }
}

Add postgres branch in buildDataSourceOptions():

if (type === 'mssql') {
  // ... existing mssql config
} else if (type === 'postgres') {
  const dbConfig = config.database.postgresql
  return {
    type: 'postgres',
    host: dbConfig.host,
    port: dbConfig.port,
    username: dbConfig.username,
    password: dbConfig.password,
    database: dbConfig.database,
    ...commonOptions
  } as DataSourceOptions
} else {
  // ... existing mysql config
}

Step 4: Update config template

In config.template.yaml, add after sqlserver section:

  postgresql:
    host: <PG_HOST>
    port: 5432
    database: <DATABASE_NAME>
    username: <USERNAME>
    password: <PASSWORD>
    maxPoolSize: 10

Update header comment to mention postgresql:

# 3. 设置 database.activeType 为 mysql、sqlserver 或 postgresql

Step 5: Run typecheck

Run: npm run typecheck Expected: PASS (all type errors from Task 1 should now be resolved)

Step 6: Run all tests

Run: npx vitest run Expected: ALL PASS

Step 7: Commit

git add src/main/services/database/index.ts src/main/services/database/data-source.ts src/main/services/config/config-manager.ts config.template.yaml
git commit -m "feat(db): integrate PostgreSQL into factory, config, and TypeORM data source"

Task 6: Verification & Smoke Test

Files:

  • Test: Manual integration verification

Step 1: Run full typecheck

Run: npm run typecheck Expected: PASS

Step 2: Run all unit tests

Run: npx vitest run Expected: ALL PASS

Step 3: Run linter

Run: npm run lint Expected: No new errors

Step 4: Format code

Run: npm run format

Step 5: Verify config.yaml can be parsed with postgresql section

Create a temporary test that validates the config schema accepts postgresql:

// In a scratch test file
import { validateConfig } from '../../src/main/types/config.schema'

const config = {
  erp: { url: 'https://example.com' },
  database: {
    activeType: 'postgresql',
    mysql: { host: 'localhost', port: 3306, database: 'test', username: 'root', password: '' },
    sqlserver: { server: 'localhost', port: 1433, database: 'test', username: 'sa', password: '' },
    postgresql: { host: '192.168.31.83', port: 5432, database: 'CompanyDB', username: 'admin', password: 'test' }
  },
  paths: { dataDir: './data/' },
  extraction: {},
  validation: {},
  cleaner: {},
  orderResolution: {},
  logging: {}
}

const result = validateConfig(config)
expect(result.success).toBe(true)

Step 6: Final commit if formatting changed

git add -A
git commit -m "chore: format and verify PostgreSQL integration"

Summary

Task Description New Files Modified Files
1 Types & SqlDialect interface 1 2
2 Three dialect implementations + tests 4 + 3 tests 0
3 PostgreSqlService + test 1 + 1 test 1 (package.json)
4 Refactor all 4 DAOs 0 4
5 Factory, config, TypeORM integration 0 4
6 Verification 0 0

Total: 6 tasks, ~14 files touched (9 new, 10 modified), 5 commits