diff --git a/docs/plans/2026-04-05-postgresql-integration-plan.md b/docs/plans/2026-04-05-postgresql-integration-plan.md new file mode 100644 index 0000000..7da5165 --- /dev/null +++ b/docs/plans/2026-04-05-postgresql-integration-plan.md @@ -0,0 +1,1299 @@ +# 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: +```typescript +export type DatabaseType = 'mysql' | 'sqlserver' | 'postgresql' +``` + +Add after line 104 (after `SqlServerConfig`): +```typescript +/** + * 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: +```typescript +export const databaseTypeSchema = z.enum(['mysql', 'sqlserver', 'postgresql']) +``` + +Add after `sqlServerConfigSchema` (after line 58): +```typescript +/** + * 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 +``` + +Change `databaseConfigSchema` (line 63): +```typescript +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`: +```typescript +/** + * 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** + +```bash +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`: +```typescript +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`: +```typescript +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`: +```typescript +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`: +```typescript +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`: +```typescript +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`: +```typescript +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`: +```typescript +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** + +```bash +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`: +```typescript +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`: +```typescript +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 { + 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 { + 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 { + 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[] + 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 { + 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** + +```bash +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: +```typescript +import { createDialect, type SqlDialect } from './dialects' +``` + +In class body, add: +```typescript +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: +```typescript +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: +```typescript +const isSqlServer = dbService.type === 'sqlserver' +const placeholder = isSqlServer ? '@p0' : '?' +``` +With: +```typescript +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: +```typescript +if (isSqlServer) { + return `@p${values.length - 1}` +} else { + return '?' +} +``` +To: +```typescript +return this.getDialect().param(values.length - 1) +``` + +**Step 5: Refactor `batchInsert()` batch size** + +Change from: +```typescript +const isSqlServer = dbService.type === 'sqlserver' +const effectiveBatchSize = isSqlServer + ? Math.min(batchSize, Math.floor(sqlServerMaxParams / columnsPerRow)) + : batchSize +``` +To: +```typescript +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: +```typescript +if (isSqlServer) { + // MERGE ... +} else { + // ON DUPLICATE KEY UPDATE ... +} +``` +With: +```typescript +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: +```typescript +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: +```typescript +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** + +```bash +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: +```typescript +import { PostgreSqlService } from './postgresql' +import type { PostgreSqlConfig } from '../../types/database.types' +``` + +Add config factory: +```typescript +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): +```typescript +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: +```typescript +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): +```typescript +postgresql: { + host: 'localhost', + port: 5432, + database: 'erp_db', + username: 'postgres', + password: '', + maxPoolSize: 10 +} +``` + +Update `getActiveDatabaseConfig()` (line 302-308): +```typescript +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()`: +```typescript +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()`: +```typescript +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: +```yaml + postgresql: + host: + port: 5432 + database: + username: + password: + maxPoolSize: 10 +``` + +Update header comment to mention postgresql: +```yaml +# 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** + +```bash +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: +```typescript +// 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** + +```bash +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