diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..04f48a1 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,37 @@ +export const DEFAULT_ROW_LIMIT = 100 +export const MAX_ROW_LIMIT = 1000 + +export const clampRowLimit = (requested?: number): number => { + if (requested === undefined) return DEFAULT_ROW_LIMIT + return Math.max(1, Math.min(Math.trunc(requested), MAX_ROW_LIMIT)) +} + +export const truncateRows = (rows: T[], limit: number): { rows: T[]; truncated: boolean } => + rows.length > limit + ? { rows: rows.slice(0, limit), truncated: true } + : { rows, truncated: false } + +export const normalizeCell = (value: unknown): unknown => { + if (typeof value === 'bigint') return value.toString() + if (value instanceof Date) return value.toISOString() + if (Buffer.isBuffer(value)) return `\\x${value.toString('hex')}` + return value +} + +type DbErrorShape = Error & { + code?: string | number + detail?: string + hint?: string + sqlMessage?: string +} + +export const formatDbError = (engine: string, error: unknown): string => { + if (error instanceof Error) { + const e = error as DbErrorShape + const code = e.code !== undefined && e.code !== null ? ` ${e.code}` : '' + const message = e.sqlMessage ?? e.message + const extras = [e.detail, e.hint].filter(Boolean).join(' | ') + return `[${engine}${code}] ${message}${extras ? ` (${extras})` : ''}` + } + return `[${engine}] ${String(error)}` +} diff --git a/test/unit/format.test.ts b/test/unit/format.test.ts new file mode 100644 index 0000000..726f008 --- /dev/null +++ b/test/unit/format.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + clampRowLimit, + DEFAULT_ROW_LIMIT, + formatDbError, + MAX_ROW_LIMIT, + normalizeCell, + truncateRows +} from '../../src/format.js' + +describe('clampRowLimit', () => { + it('defaults when undefined', () => { + expect(clampRowLimit(undefined)).toBe(DEFAULT_ROW_LIMIT) + }) + + it('clamps to max and min and truncates fractions', () => { + expect(clampRowLimit(99999)).toBe(MAX_ROW_LIMIT) + expect(clampRowLimit(0)).toBe(1) + expect(clampRowLimit(-5)).toBe(1) + expect(clampRowLimit(10.9)).toBe(10) + }) +}) + +describe('truncateRows', () => { + it('passes short arrays through', () => { + expect(truncateRows([1, 2], 5)).toEqual({ rows: [1, 2], truncated: false }) + }) + + it('slices long arrays and flags truncation', () => { + const { rows, truncated } = truncateRows([1, 2, 3], 2) + expect(rows).toEqual([1, 2]) + expect(truncated).toBe(true) + }) +}) + +describe('normalizeCell', () => { + it('converts bigint to string', () => { + expect(normalizeCell(123n)).toBe('123') + }) + + it('converts Date to ISO string', () => { + expect(normalizeCell(new Date('2026-01-02T03:04:05Z'))).toBe('2026-01-02T03:04:05.000Z') + }) + + it('converts Buffer to hex string', () => { + expect(normalizeCell(Buffer.from([0xde, 0xad]))).toBe('\\xdead') + }) + + it('passes primitives and null through', () => { + expect(normalizeCell('x')).toBe('x') + expect(normalizeCell(5)).toBe(5) + expect(normalizeCell(null)).toBeNull() + }) +}) + +describe('formatDbError', () => { + it('formats code, detail and hint', () => { + const error = Object.assign(new Error('relation "x" does not exist'), { + code: '42P01', + hint: 'check the table name' + }) + expect(formatDbError('postgres', error)).toBe( + '[postgres 42P01] relation "x" does not exist (check the table name)' + ) + }) + + it('prefers sqlMessage for mysql errors', () => { + const error = Object.assign(new Error('outer'), { + code: 'ER_NO_SUCH_TABLE', + sqlMessage: "Table 'a.b' doesn't exist" + }) + expect(formatDbError('mysql', error)).toBe( + "[mysql ER_NO_SUCH_TABLE] Table 'a.b' doesn't exist" + ) + }) + + it('handles non-Error values', () => { + expect(formatDbError('mysql', 'boom')).toBe('[mysql] boom') + }) +})