Add a SQLite driver for database, for tests

This commit is contained in:
Guangcong Luo
2026-07-16 11:35:37 -07:00
parent aeb21f2373
commit 247d12251d
8 changed files with 235 additions and 60 deletions

View File

@@ -1,13 +1,14 @@
/**
* @typedef {'mysql' | 'postgres' | 'mock'} DatabaseDriver
* @typedef {{driver?: DatabaseDriver, prefix?: string}} DatabaseConfig
* @typedef {import('mysql2').PoolOptions & DatabaseConfig} MySQLDatabaseConfig
* @typedef {import('pg').PoolConfig & DatabaseConfig} PGDatabaseConfig
* @typedef {'mysql' | 'postgres' | 'sqlite' | 'mock'} DatabaseDriver
* @typedef {{driver?: DatabaseDriver, prefix?: string, path?: string}} BasicDatabaseConfig
* @typedef {import('mysql2').PoolOptions & BasicDatabaseConfig} MySQLDatabaseConfig
* @typedef {import('pg').PoolConfig & BasicDatabaseConfig} PGDatabaseConfig
* @typedef {MySQLDatabaseConfig | PGDatabaseConfig} DatabaseConfig
*/
/**
* For logins and ladders
* @type {MySQLDatabaseConfig}
* @type {DatabaseConfig}
*/
exports.mysql = {
driver: "mysql",
@@ -22,7 +23,7 @@ exports.mysql = {
/**
* For replays
* @type {PGDatabaseConfig | undefined}
* @type {DatabaseConfig | null | undefined}
*/
exports.replaysdb = {
driver: "postgres",
@@ -35,13 +36,13 @@ exports.replaysdb = {
/**
* For ladders
* @type {MySQLDatabaseConfig | undefined}
* @type {DatabaseConfig | null | undefined}
*/
exports.ladderdb = undefined;
exports.ladderdb = null;
/**
* For friends
* @type {PGDatabaseConfig | null}
* @type {DatabaseConfig | null | undefined}
*/
exports.postgres = null;

View File

@@ -14,13 +14,16 @@ export type Configuration = typeof defaults;
export function load(invalidate = false): Configuration {
if (process.env.NODE_TEST_CONTEXT) {
const databasePath = path.resolve(__dirname, '../../src/test/fixtures/database.sql');
return {
...defaults,
watchconfig: false,
loadprivaterelayips: false,
serverlist: path.resolve(__dirname, '../../src/test/fixtures/servers.php'),
mysql: { driver: 'mock' },
postgres: { driver: 'mock' },
mysql: { driver: 'mock', path: databasePath },
postgres: { driver: 'mock', path: databasePath },
replaysdb: { driver: 'mock', path: databasePath },
ladderdb: { driver: 'mock', path: databasePath },
};
}

View File

@@ -1,39 +0,0 @@
/**
* Mock database driver.
*/
import { type DatabaseTable } from './database';
type MockTableBase<Row> = Pick<DatabaseTable<Row, any>, 'name' | 'primaryKeyName' | 'db'>;
function createMockTable<Row>(
name: string, primaryKeyName: keyof Row & string | null = null
): DatabaseTable<Row, any> {
const table: MockTableBase<Row> = {
name,
primaryKeyName,
db: { type: 'mock' } as any,
};
return new Proxy(table, {
get(target, prop, receiver) {
if (prop in target) return Reflect.get(target, prop, receiver);
if (typeof prop === 'string') {
return () => {
throw new Error(`Mock table "${name}" attempted to use "${prop}".`);
};
}
},
}) as DatabaseTable<Row, any>;
}
export class MockDatabase {
type = 'mock';
prefix = '';
readonly name: string;
constructor(_config: any, name: string) {
this.name = name;
}
getTable<Row>(tableName: string, primaryKeyName: keyof Row & string | null = null) {
return createMockTable<Row>(`${this.name}.${tableName}`, primaryKeyName);
}
close() {}
}

View File

@@ -6,6 +6,8 @@
* @author Zarel
*/
import * as fs from 'fs';
import { DatabaseSync } from 'node:sqlite';
import * as mysql from 'mysql2';
import * as pg from 'pg';
@@ -114,14 +116,14 @@ export function SQL(strings: TemplateStringsArray, ...values: SQLValue[]) {
export interface ResultRow { [k: string]: BasicSQLValue }
export const connectedDatabases: Database[] = [];
export const connectedDatabases: Database<any, any>[] = [];
export async function closeDatabases() {
const databases = connectedDatabases.splice(0);
await Promise.all(databases.map(database => database.close()));
}
export abstract class Database<Pool extends mysql.Pool | pg.Pool = mysql.Pool | pg.Pool, OkPacket = unknown> {
export abstract class Database<Pool = mysql.Pool | pg.Pool, OkPacket = unknown> {
connection: Pool;
prefix: string;
type = '';
@@ -170,7 +172,7 @@ type PartialOrSQL<T> = {
type OkPacketOf<DB extends Database> = DB extends Database<any, infer T> ? T : never;
// Row extends SQLRow but TS doesn't support closed types so we can't express this
export class DatabaseTable<Row, DB extends Database> {
export class DatabaseTable<Row, DB extends Database<any, any>> {
db: DB;
name: string;
primaryKeyName: keyof Row & string | null;
@@ -228,6 +230,13 @@ export class DatabaseTable<Row, DB extends Database> {
}
updateOne(partialRow: PartialOrSQL<Row>):
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
if (this.db.type === 'sqlite') {
// sqlite usually doesn't support UPDATE ... LIMIT 1
return (strings, ...rest) => this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any}
WHERE rowid = (
SELECT rowid FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1
)`;
}
return (s, ...r) =>
this.queryExec()`UPDATE "${this.name}" SET ${partialRow as any} ${new SQLStatement(s, r)} LIMIT 1`;
}
@@ -238,6 +247,13 @@ export class DatabaseTable<Row, DB extends Database> {
}
deleteOne():
(strings: TemplateStringsArray, ...rest: SQLValue[]) => Promise<OkPacketOf<DB>> {
if (this.db.type === 'sqlite') {
// sqlite usually doesn't support DELETE ... LIMIT 1
return (strings, ...rest) => this.queryExec()`DELETE FROM "${this.name}"
WHERE rowid = (
SELECT rowid FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1
)`;
}
return (strings, ...rest) =>
this.queryExec()`DELETE FROM "${this.name}" ${new SQLStatement(strings, rest)} LIMIT 1`;
}
@@ -255,6 +271,10 @@ export class DatabaseTable<Row, DB extends Database> {
return this.queryExec()`INSERT INTO "${this.name}" (${partialRow as SQLValue}) ${where}`;
}
insertIgnore(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
if (this.db.type === 'sqlite') {
// sqlite usually doesn't support DELETE ... LIMIT 1
return this.queryExec()`INSERT OR IGNORE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`;
}
return this.queryExec()`INSERT IGNORE INTO "${this.name}" (${partialRow as SQLValue}) ${where}`;
}
async tryInsert(partialRow: PartialOrSQL<Row>, where?: SQLStatement) {
@@ -264,10 +284,20 @@ export class DatabaseTable<Row, DB extends Database> {
if (err.code === 'ER_DUP_ENTRY') {
return undefined;
}
if (
this.db.type === 'sqlite' &&
[1555, 2067].includes(err.errcode)
) return undefined;
throw err;
}
}
upsert(partialRow: PartialOrSQL<Row>, partialUpdate = partialRow, where?: SQLStatement) {
if (this.db.type === 'sqlite') {
if (!this.primaryKeyName) throw new Error(`Cannot upsert() without a single-column primary key`);
return this.queryExec(
)`INSERT INTO "${this.name}" (${partialRow as any}) ON CONFLICT ("${this.primaryKeyName
}") DO UPDATE SET ${partialUpdate as any} ${where}`;
}
if (this.db.type === 'pg') {
return this.queryExec(
)`INSERT INTO "${this.name}" (${partialRow as any}) ON CONFLICT (${this.primaryKeyName
@@ -290,6 +320,9 @@ export class DatabaseTable<Row, DB extends Database> {
}
delete(primaryKey: BasicSQLValue) {
if (!this.primaryKeyName) throw new Error(`Cannot delete() without a single-column primary key`);
if (this.db.type === 'sqlite') {
return this.deleteAll()`WHERE "${this.primaryKeyName}" = ${primaryKey}`;
}
return this.deleteAll()`WHERE "${this.primaryKeyName}" = ${primaryKey} LIMIT 1`;
}
update(primaryKey: BasicSQLValue, data: PartialOrSQL<Row>) {
@@ -391,3 +424,71 @@ export class PGDatabase extends Database<pg.Pool, { affectedRows: number | null
return this.connection.end();
}
}
export interface SQLiteDatabaseConfig {
path?: string;
prefix?: string;
}
export interface SQLiteResult {
affectedRows: number;
insertId: number;
}
export class SQLiteDatabase extends Database<DatabaseSync, SQLiteResult> {
override type = 'sqlite';
constructor(config: SQLiteDatabaseConfig = {}) {
super(new DatabaseSync(config.path || ':memory:'), config.prefix || '');
}
override _resolveSQL(query: SQLStatement): [query: string, values: BasicSQLValue[]] {
let sql = query.sql[0];
const values = [];
for (let i = 0; i < query.values.length; i++) {
const value = query.values[i];
if (query.sql[i + 1].startsWith('`') || query.sql[i + 1].startsWith('"')) {
sql = sql.slice(0, -1) + this.escapeId(`${value as any}`) + query.sql[i + 1].slice(1);
} else {
sql += '?' + query.sql[i + 1];
values.push(value);
}
}
return [sql, values];
}
override _query(query: string, values: BasicSQLValue[]) {
return Promise.resolve().then(() => {
const rows = this.connection.prepare(query).all(...values);
return rows.map(row => ({ ...(row as Record<string, unknown>) }));
});
}
override _queryExec(query: string, values: BasicSQLValue[]) {
return Promise.resolve().then(() => {
const result = this.connection.prepare(query).run(...values);
return {
affectedRows: Number(result.changes),
insertId: Number(result.lastInsertRowid),
};
});
}
override escapeId(id: string) {
return `"${id.replace(/"/g, '""')}"`;
}
override close() {
return Promise.resolve().then(() => this.connection.close());
}
}
export class MockDatabase extends SQLiteDatabase {
readonly name: string;
constructor(config: SQLiteDatabaseConfig | null | undefined, name: string) {
super({ path: ':memory:', prefix: config?.prefix });
this.name = name;
if (config?.path) this.connection.exec(fs.readFileSync(config.path, 'utf8'));
}
}

View File

@@ -8,6 +8,7 @@ import * as https from 'https';
import * as child from 'child_process';
import * as dns from 'dns';
import * as fs from 'fs';
import * as net from 'net';
import { toID, md5 } from './utils';
import { Config } from './config-loader';
import { actions } from './actions';
@@ -331,14 +332,14 @@ export class Server {
awaitingEnd?: () => void;
closing?: Promise<void>;
activeRequests = 0;
constructor(port = (Config.port || 8000), host = (Config.bindaddress || "0.0.0.0")) {
constructor(port: number | null = (Config.port || 8000), host = (Config.bindaddress || "0.0.0.0")) {
this.host = host;
this.port = port;
this.port = port || 0;
this.server = http.createServer((req, res) => void this.handle(req, res));
this.server.listen(port, host);
if (port !== null) this.server.listen(port, host);
this.httpsServer = null;
if (Config.ssl) {
if (Config.ssl && port !== null) {
this.httpsServer = https.createServer(Config.ssl, (req, res) => void this.handle(req, res));
this.httpsServer.listen(Config.ssl.port || 8043);
}
@@ -402,11 +403,45 @@ export class Server {
this.activeRequests--;
if (!this.activeRequests) this.awaitingEnd?.();
}
async request(url: string, bodyData?: Partial<ActionRequest>) {
const socket = new net.Socket();
const req = new http.IncomingMessage(socket);
req.url = url;
req.method = bodyData ? 'POST' : 'GET';
if (bodyData) {
req.push(new URLSearchParams(bodyData as Record<string, string>).toString());
}
req.push(null);
const res = new http.ServerResponse(req);
let body = '';
const result = new Promise<{
statusCode: number,
body: string,
}>(resolve => {
const originalWrite = res.write.bind(res);
const originalEnd = res.end.bind(res);
res.write = ((chunk: any, ...args: any[]) => {
if (chunk) body += chunk;
return originalWrite(chunk, ...args);
}) as typeof res.write;
res.end = ((chunk: any, ...args: any[]) => {
if (chunk) body += chunk;
originalEnd(chunk, ...args);
resolve({ statusCode: res.statusCode, body });
return res;
}) as typeof res.end;
});
await this.handle(req, res);
return result;
}
ensureHeaders(res: http.ServerResponse) {
if (this.awaitingEnd) res.setHeader('Connection', 'close');
}
close() {
if (this.closing) return this.closing;
if (!this.server.listening && !this.activeRequests) return Promise.resolve();
this.server.close();
if (!this.activeRequests) return Promise.resolve();
this.closing = new Promise<void>(resolve => {

View File

@@ -1,15 +1,14 @@
/**
* Login server database tables
*/
import { MySQLDatabase, PGDatabase } from './database';
import { MockDatabase } from './database-mock';
import { MockDatabase, MySQLDatabase, PGDatabase, SQLiteDatabase } from './database';
import { Config } from './config-loader';
import type { LadderEntry } from './ladder';
import type { ReplayRow } from './replays';
import type { Suspect } from './actions';
type DatabaseDriver = 'mysql' | 'postgres' | 'mock';
type DatabaseDriver = 'mysql' | 'postgres' | 'sqlite' | 'mock';
type DatabaseConfig = {
driver?: DatabaseDriver,
prefix?: string,
@@ -28,6 +27,7 @@ function createDatabase<DB extends RealDatabase>(
const driver = config?.driver || defaultDriver;
if (driver === 'mock') return new MockDatabase(config, name) as unknown as DB;
if (!config) throw new Error(`Database config "${name}" is required for ${driver}.`);
if (driver === 'sqlite') return new SQLiteDatabase(stripDriver(config)) as unknown as DB;
if (driver === 'mysql') return new MySQLDatabase(stripDriver(config) as any) as DB;
if (driver === 'postgres') return new PGDatabase(stripDriver(config) as any) as DB;
throw new Error(`Unsupported database driver for ${name}.`);

52
src/test/fixtures/database.sql vendored Normal file
View File

@@ -0,0 +1,52 @@
CREATE TABLE replays (
id TEXT PRIMARY KEY,
format TEXT NOT NULL,
players TEXT NOT NULL,
log TEXT NOT NULL,
inputlog TEXT,
uploadtime INTEGER NOT NULL,
views INTEGER NOT NULL DEFAULT 0,
formatid TEXT NOT NULL,
rating INTEGER,
private INTEGER NOT NULL DEFAULT 0,
password TEXT
);
INSERT INTO replays (
id, format, players, log, inputlog, uploadtime, views, formatid, rating, private, password
) VALUES (
'oumonotype-82345404', 'OU Monotype', 'kdarewolf,Onox',
'|join|kdarewolf
|join|Onox
|player|p1|kdarewolf|37
|player|p2|Onox|159
|gametype|singles
|gen|6
|tier|OU Monotype
|clearpoke
|poke|p1|Kecleon, F, shiny
|poke|p1|Diggersby, M
|poke|p1|Girafarig, M, shiny
|poke|p1|Heliolisk, F
|poke|p1|Chansey, F, shiny
|poke|p1|Staraptor, F, shiny
|poke|p2|Espeon, M, shiny
|poke|p2|Metagross, shiny
|poke|p2|Reuniclus, M, shiny
|poke|p2|Alakazam, M, shiny
|poke|p2|Delphox, M, shiny
|poke|p2|Gardevoir, M, shiny
|teampreview
|callback|decision
|
|start
|switch|p1a: May Day Parade|Kecleon, F, shiny|324/324
|switch|p2a: AMagicalFox|Delphox, M, shiny|292/292
|turn|1
|callback|decision
|
|move|p1a: May Day Parade|Fake Out|p2a: AMagicalFox
|-damage|p2a: AMagicalFox|213/292
|cant|p2a: AMagicalFox|flinch',
NULL, 1390960565, 5468, '', NULL, 0, NULL
);

View File

@@ -1,7 +1,9 @@
import { strict as assert } from 'assert';
import * as http from 'http';
import test from 'node:test';
import { Replays } from '../replays';
import { Server } from '../server';
import { replays } from '../tables';
async function waitForListening(server: Server) {
await new Promise<void>((resolve, reject) => {
@@ -68,3 +70,23 @@ void test('/api/test reports unknown request type', async () => {
await closeServer(server);
}
});
void test('/api/replays/get.json uses the mock database', async () => {
const server = new Server(null);
try {
assert.equal(replays.db.type, 'sqlite');
const firstResponse = await server.request('/api/replays/get.json', { id: 'oumonotype-82345404' });
assert.equal(firstResponse.statusCode, 200);
assert.equal(JSON.parse(firstResponse.body).views, 5468);
const replay = await Replays.get('oumonotype-82345404');
assert(replay);
assert.equal(replay.views, 5468);
const secondResponse = await server.request('/api/replays/get.json', { id: 'oumonotype-82345404' });
assert.equal(secondResponse.statusCode, 200);
assert.equal(JSON.parse(secondResponse.body).views, 5469);
} finally {
await server.close();
}
});