diff --git a/package-lock.json b/package-lock.json index 764ce21..13140e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 471ddae..aa18b23 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/services/database/postgresql.ts b/src/main/services/database/postgresql.ts new file mode 100644 index 0000000..569cf01 --- /dev/null +++ b/src/main/services/database/postgresql.ts @@ -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 { + 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 { + 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 { + 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[]) || [] + 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 { + 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() + } + } +} diff --git a/tests/unit/postgresql.test.ts b/tests/unit/postgresql.test.ts new file mode 100644 index 0000000..7dcaa00 --- /dev/null +++ b/tests/unit/postgresql.test.ts @@ -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() + }) + }) +})