318 lines
11 KiB
TypeScript
318 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('sets a connect timeout on every pool', async () => {
|
|
const driver = createMysqlDriver(target())
|
|
await driver.query({ sql: 'select 1', rowLimit: 10 })
|
|
expect(mysqlState.state.pools[0].options.connectTimeout).toBe(10_000)
|
|
})
|
|
|
|
it('returns DATE and DATETIME as strings to avoid tz day-shift', async () => {
|
|
const driver = createMysqlDriver(target())
|
|
await driver.query({ sql: 'select 1', rowLimit: 10 })
|
|
expect(mysqlState.state.pools[0].options.dateStrings).toEqual(['DATE', 'DATETIME'])
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|