feat(db): add PostgreSqlService with pg driver
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
161
package-lock.json
generated
161
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -5020,6 +5022,18 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/plist": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz",
|
||||
@@ -13532,6 +13546,96 @@
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -13641,6 +13745,45 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
@@ -14912,6 +15055,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
@@ -17836,6 +17988,15 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"lucide-react": "^0.575.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"pg": "^8.20.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright-core": "^1.58.2",
|
||||
"react-focus-lock": "^2.13.7",
|
||||
@@ -72,6 +73,7 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
|
||||
161
src/main/services/database/postgresql.ts
Normal file
161
src/main/services/database/postgresql.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Pool } from 'pg'
|
||||
import type {
|
||||
IDatabaseService,
|
||||
DatabaseType,
|
||||
QueryResult,
|
||||
PostgreSqlConfig
|
||||
} from '../../types/database.types'
|
||||
import { createLogger, trackDuration } from '../logger'
|
||||
|
||||
const log = createLogger('PostgreSqlService')
|
||||
|
||||
export type { PostgreSqlConfig } from '../../types/database.types'
|
||||
|
||||
export class PostgreSqlService implements IDatabaseService {
|
||||
/** Database type identifier */
|
||||
readonly type: DatabaseType = 'postgresql'
|
||||
|
||||
private pool: Pool | null = null
|
||||
private config: PostgreSqlConfig
|
||||
|
||||
constructor(config: PostgreSqlConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to PostgreSQL database
|
||||
*/
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from PostgreSQL database
|
||||
*/
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to PostgreSQL database
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.pool !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return results
|
||||
*/
|
||||
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)
|
||||
|
||||
// Extract column names from fields
|
||||
const columns = result.fields ? result.fields.map((field) => field.name) : []
|
||||
|
||||
// Result rows
|
||||
const rows = (result.rows as Record<string, unknown>[]) || []
|
||||
const rowCount = result.rowCount ?? rows.length
|
||||
|
||||
return { rows, columns, rowCount }
|
||||
},
|
||||
{ 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
*/
|
||||
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
|
||||
log.info('Transaction started', { queryCount })
|
||||
|
||||
const client = await this.pool.connect()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
70
tests/unit/postgresql.test.ts
Normal file
70
tests/unit/postgresql.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for PostgreSqlService
|
||||
* These tests do not require a PostgreSQL instance
|
||||
*/
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('should resolve when not connected', async () => {
|
||||
await expect(service.disconnect()).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user