1342 lines
36 KiB
Markdown
1342 lines
36 KiB
Markdown
# 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<typeof postgresqlConfigSchema>
|
|
```
|
|
|
|
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<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**
|
|
|
|
```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: <PG_HOST>
|
|
port: 5432
|
|
database: <DATABASE_NAME>
|
|
username: <USERNAME>
|
|
password: <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
|