Files
dbmole-mcp/test/unit/db/mysql.test.ts
T
2026-06-11 23:58:14 +05:00

306 lines
11 KiB
TypeScript

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<string, unknown>
// The physical connection shared across every checkout wrapper.
connection = new FakeConnection()
wrappers: FakeWrapper[] = []
ended = false
constructor(options: Record<string, unknown>) {
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<string, unknown>) => new mysqlState.FakeMysqlPool(options)
}
}))
import type { ConnectionConfig } from '../../../src/config/types.js'
import { createMysqlDriver } from '../../../src/db/mysql.js'
const config = (extra: Partial<ConnectionConfig> = {}): ConnectionConfig => ({
name: 'my',
type: 'mysql',
host: 'real-host',
user: 'root',
password: 'pw',
database: 'main',
readonly: false,
...extra
})
const target = (extra: Partial<ConnectionConfig> = {}) => ({
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)
})
})