import { beforeEach, describe, expect, it, vi } from 'vitest' const mysqlState = vi.hoisted(() => { const state = { pools: [] as FakeMysqlPool[], nextResult: undefined as unknown, nextFields: undefined as unknown, resultQueue: [] as Array<[unknown, unknown]> } // A single FakeConnection instance models ONE physical connection and // accumulates every query routed through any wrapper that shares it. class FakeConnection { queries: unknown[] = [] released = 0 async query(args: unknown) { this.queries.push(args) // The readonly SET runs as a bare string; never consume the queue // for it so describeTable's catalog reads stay aligned. if (typeof args !== 'string' && state.resultQueue.length > 0) { return state.resultQueue.shift() } return [state.nextResult ?? [], state.nextFields ?? []] } release() { this.released += 1 } } // A checkout wrapper. mysql2 hands back a fresh PromisePoolConnection per // getConnection() but the underlying physical conn (.connection) is stable. class FakeWrapper { connection: FakeConnection queries: unknown[] released = 0 constructor(physical: FakeConnection) { this.connection = physical // Route queries to the physical conn so query history is shared. this.queries = physical.queries } async query(args: unknown) { return this.connection.query(args) } release() { this.released += 1 } } class FakeMysqlPool { options: Record // The physical connection shared across every checkout wrapper. connection = new FakeConnection() wrappers: FakeWrapper[] = [] ended = false constructor(options: Record) { this.options = options state.pools.push(this) } async getConnection() { const wrapper = new FakeWrapper(this.connection) this.wrappers.push(wrapper) return wrapper } async end() { this.ended = true } } return { state, FakeMysqlPool } }) vi.mock('mysql2/promise', () => ({ default: { createPool: (options: Record) => new mysqlState.FakeMysqlPool(options) } })) import type { ConnectionConfig } from '../../../src/config/types.js' import { createMysqlDriver } from '../../../src/db/mysql.js' const config = (extra: Partial = {}): ConnectionConfig => ({ name: 'my', type: 'mysql', host: 'real-host', user: 'root', password: 'pw', database: 'main', readonly: false, ...extra }) const target = (extra: Partial = {}) => ({ config: config(extra), host: '127.0.0.1', port: 13306 }) describe('createMysqlDriver', () => { beforeEach(() => { mysqlState.state.pools.length = 0 mysqlState.state.nextResult = undefined mysqlState.state.nextFields = undefined mysqlState.state.resultQueue.length = 0 }) it('creates pools with multipleStatements disabled', async () => { const driver = createMysqlDriver(target()) await driver.query({ sql: 'select 1', rowLimit: 10 }) expect(mysqlState.state.pools[0].options).toMatchObject({ host: '127.0.0.1', port: 13306, database: 'main', connectionLimit: 4, multipleStatements: false, supportBigNumbers: true, bigNumberStrings: true, maxIdle: 1 }) }) it('reuses pools per database', async () => { const driver = createMysqlDriver(target()) await driver.query({ sql: 'select 1', rowLimit: 10 }) await driver.query({ sql: 'select 2', rowLimit: 10 }) await driver.query({ sql: 'select 3', database: 'other', rowLimit: 10 }) expect(mysqlState.state.pools).toHaveLength(2) }) it('applies SET SESSION TRANSACTION READ ONLY once per physical connection when readonly', async () => { const driver = createMysqlDriver(target({ readonly: true })) await driver.query({ sql: 'select 1', rowLimit: 10 }) await driver.query({ sql: 'select 2', rowLimit: 10 }) // Each checkout returns a fresh wrapper, but they share one physical conn. expect(mysqlState.state.pools[0].wrappers).toHaveLength(2) const queries = mysqlState.state.pools[0].connection.queries const readonlySets = queries.filter((q) => q === 'SET SESSION TRANSACTION READ ONLY') expect(readonlySets).toHaveLength(1) expect(queries[0]).toBe('SET SESSION TRANSACTION READ ONLY') }) it('does not set readonly for writable connections', async () => { const driver = createMysqlDriver(target()) await driver.query({ sql: 'select 1', rowLimit: 10 }) const queries = mysqlState.state.pools[0].connection.queries expect(queries.some((q) => q === 'SET SESSION TRANSACTION READ ONLY')).toBe(false) }) it('rejects session-level statements', async () => { const driver = createMysqlDriver(target()) await expect( driver.query({ sql: 'set session transaction read write', rowLimit: 1 }) ).rejects.toThrow(/session-level/) expect(mysqlState.state.pools).toHaveLength(0) }) it('rejects multi-statement sql before touching the pool', async () => { const driver = createMysqlDriver(target()) await expect(driver.query({ sql: 'select 1; select 2', rowLimit: 1 })).rejects.toThrow( /one SQL statement/ ) expect(mysqlState.state.pools).toHaveLength(0) }) it('maps SELECT results with columns and normalized cells', async () => { mysqlState.state.nextResult = [[1n, 'x']] mysqlState.state.nextFields = [{ name: 'id' }, { name: 'label' }] const driver = createMysqlDriver(target()) const result = await driver.query({ sql: 'select * from t', rowLimit: 10 }) expect(result).toEqual({ columns: ['id', 'label'], rows: [['1', 'x']], rowCount: 1, truncated: false }) }) it('maps DML results to rowCount and lastInsertId', async () => { mysqlState.state.nextResult = { affectedRows: 3, insertId: 7 } const driver = createMysqlDriver(target()) const result = await driver.query({ sql: 'insert into t values (1)', rowLimit: 10 }) expect(result).toEqual({ columns: [], rows: [], rowCount: 3, truncated: false, lastInsertId: '7' }) }) it('omits lastInsertId when zero', async () => { mysqlState.state.nextResult = { affectedRows: 1, insertId: 0 } const driver = createMysqlDriver(target()) const result = await driver.query({ sql: 'update t set a=1', rowLimit: 10 }) expect(result.lastInsertId).toBeUndefined() }) it('listTables resolves schema > database > connection default', async () => { mysqlState.state.nextResult = [['main', 'users', 10]] mysqlState.state.nextFields = [] const driver = createMysqlDriver(target()) await driver.listTables({}) await driver.listTables({ database: 'db-param' }) await driver.listTables({ schema: 'schema-param' }) const calls = mysqlState.state.pools.flatMap((pool) => pool.connection.queries.filter( (q): q is { sql: string; values: unknown[] } => typeof q === 'object' && q !== null && 'values' in (q as object) ) ) expect(calls[0].values).toEqual(['main']) expect(calls[1].values).toEqual(['db-param']) expect(calls[2].values).toEqual(['schema-param']) }) it('lists databases and maps sizes', async () => { mysqlState.state.nextResult = [ ['app', 2048], ['shop', null] ] mysqlState.state.nextFields = [] const driver = createMysqlDriver(target()) const databases = await driver.listDatabases() expect(databases).toEqual([ { name: 'app', sizeBytes: 2048 }, { name: 'shop', sizeBytes: null } ]) }) it('describeTable groups pk, composite fks and multi-column indexes', async () => { mysqlState.state.resultQueue = [ // columns [ [ ['id', 'int', 'NO', null], ['name', 'varchar(20)', 'YES', null] ], [] ], // index statistics: PRIMARY plus a composite secondary index [ [ ['PRIMARY', 0, 'id'], ['idx_ab', 1, 'a'], ['idx_ab', 1, 'b'] ], [] ], // foreign keys: one composite fk with two column pairs [ [ ['fk1', 'a', 'other', 'x'], ['fk1', 'b', 'other', 'y'] ], [] ] ] const driver = createMysqlDriver(target()) const description = await driver.describeTable({ table: 't' }) expect(description.primaryKey).toEqual(['id']) expect(description.indexes).toEqual([ { name: 'idx_ab', unique: false, columns: ['a', 'b'] } ]) expect(description.foreignKeys).toEqual([ { name: 'fk1', columns: ['a', 'b'], referencedTable: 'other', referencedColumns: ['x', 'y'] } ]) expect(description.columns).toEqual([ { name: 'id', type: 'int', nullable: false, default: null }, { name: 'name', type: 'varchar(20)', nullable: true, default: null } ]) }) it('describeTable throws when the table has no columns', async () => { mysqlState.state.resultQueue = [[[], []]] const driver = createMysqlDriver(target()) await expect(driver.describeTable({ table: 'missing' })).rejects.toThrow(/not found/) }) it('serverVersion returns the reported version', async () => { mysqlState.state.nextResult = [['8.0.36']] mysqlState.state.nextFields = [] const driver = createMysqlDriver(target()) expect(await driver.serverVersion()).toBe('8.0.36') }) it('dispose ends all pools', async () => { const driver = createMysqlDriver(target()) await driver.query({ sql: 'select 1', rowLimit: 1 }) await driver.listDatabases() await driver.dispose() expect(mysqlState.state.pools.every((pool) => pool.ended)).toBe(true) }) })