mirror of
https://github.com/smogon/pokemon-showdown-client.git
synced 2026-08-28 06:01:11 -05:00
Add a SQLite driver for database, for tests
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
107
src/database.ts
107
src/database.ts
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
52
src/test/fixtures/database.sql
vendored
Normal 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
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user