Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The same binary loads on SA-MP and on Open Multiplayer — natively as a compone
### Highlights

- **Zero external dependencies** — no `libmysqlclient`, no OpenSSL. The MySQL protocol and TLS (via rustls) are compiled directly into the binary.
- **All queries are non-blocking** — `mysql_query` runs on background threads with FIFO ordering. The server never stalls.
- **All queries are non-blocking** — `mysql_query` runs on background threads and the server never stalls. **That does not mean a callback for everything:** the callback is optional, so a write is one line (`mysql_query(conn, "UPDATE …")`) with no forward and no public. You pass a callback only to read a result back.
- **Connection pool** — automatic reuse through `mysql::Pool`, thread-safe by design, with a configurable ceiling.
- **Credentials out of the source** — `mysql_connect_file` reads them from a config file your repository does not have to carry.
- **Schema scripts** — `mysql_query_file` runs a `.sql` file's statements in order, non-blocking.
Expand Down Expand Up @@ -82,8 +82,11 @@ public OnGameModeInit() {
return 1;
}

// Non-blocking query with callback
// Reading a result: needs a callback, because the rows arrive later
mysql_query(gMysql, "SELECT * FROM players LIMIT 10", "OnPlayersLoaded");

// Writing: no callback, no forward, no public - and still non-blocking
mysql_query(gMysql, "UPDATE server_state SET last_start = NOW()");
return 1;
}

Expand All @@ -105,7 +108,7 @@ public OnGameModeExit() {
}
```

Browse the [examples/](examples/) folder for self-contained `.pwn` scripts covering connection setup, threaded queries, ORM, TLS, error handling, prepared statements, transactions, password hashing, config files, `.sql` scripts and multi-result sets. For anything carrying player input, start with [`08_prepared_statements.pwn`](examples/08_prepared_statements.pwn). The plugin natives (`mysql_*`, `cache_*`, `orm_*`) and the `OnQueryError` forward are identical across SA-MP and Open Multiplayer, so every example builds and runs on both — the only thing that differs between servers is the installation path documented above.
Browse the [examples/](examples/) folder for self-contained `.pwn` scripts covering connection setup, threaded queries, ORM, TLS, error handling, prepared statements, transactions, password hashing, config files, `.sql` scripts and multi-result sets. For anything carrying player input, start with [`08_prepared_statements.pwn`](examples/08_prepared_statements.pwn); for the callback-free form and for what is and is not ordered, see [`12_no_callback.pwn`](examples/12_no_callback.pwn) and [`13_ordering.pwn`](examples/13_ordering.pwn). The plugin natives (`mysql_*`, `cache_*`, `orm_*`) and the `OnQueryError` forward are identical across SA-MP and Open Multiplayer, so every example builds and runs on both — the only thing that differs between servers is the installation path documented above.

## Documentation

Expand Down
35 changes: 32 additions & 3 deletions docs/queries.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Queries

Every query in mysql_samp is **non-blocking**: the statement runs on a worker thread and the callback is invoked on a later tick. The server never freezes waiting for the database.
Every query in mysql_samp is **non-blocking**: the statement runs on a worker thread and the result reaches your script on a later tick. The server never freezes waiting for the database.

**The callback is optional.** Non-blocking does not mean "you have to write a callback for everything" — every native on this page takes `callback` with a default of `""`, and passing nothing means fire-and-forget:

```pawn
// One line, no callback, no forward, no public. The server keeps running.
mysql_query(g_mysql, "UPDATE players SET last_login = NOW() WHERE id = 1");
```

That covers most of what a script does with a database: `UPDATE`, `INSERT`, `DELETE`. You only need a callback when you want to **read** the result back, because a value that arrives later has nowhere else to arrive. A failure still reaches [`OnQueryError`](errors.md) either way, so a discarded result is not a silent one.

The two exceptions are [`mysql_hash_password` and `mysql_verify_password`](security.md#password-storage): their callback is required, since the hash or the match result exists only there. Called with an empty callback they log a warning and return `false`.

## mysql_query — FIFO-ordered

Expand All @@ -18,7 +29,24 @@ native bool:mysql_query(connId, const query[], const callback[] = "", const form

**Returns:** `true` if the query was queued, `false` if `connId` is unknown.

**Ordering guarantee:** callbacks are delivered in **submission order**, even when the underlying queries finish out of order. A slow query blocks the dispatch of later callbacks until it completes.
**Ordering guarantee — read this one carefully.** What is ordered is the **delivery of callbacks**, in submission order, even when the underlying queries finish out of order. A slow query holds back the dispatch of later callbacks until it completes.

**Execution is not ordered.** Each query gets its own worker thread and its own pool connection the moment you submit it, so they run *at the same time* on the server. Two statements where the second depends on the first will race:

```pawn
// BROKEN: these run concurrently. The INSERT usually fails with
// "Table 'x' doesn't exist" because the CREATE has not finished yet.
mysql_query(g_mysql, "CREATE TABLE x (id INT)");
mysql_query(g_mysql, "INSERT INTO x (id) VALUES (1)");
```

When one statement depends on another, pick the tool that actually serialises them:

| What you need | Use |
|---|---|
| Schema setup, migrations, any `.sql` script | [`mysql_query_file`](#running-a-sql-file) — statements run in order, on one connection |
| Writes that depend on each other | [`mysql_transaction_*`](#transactions) — one connection, in order, all-or-nothing |
| A read that depends on a write | The callback of the write |

```pawn
mysql_query(g_mysql, "SELECT * FROM players WHERE level > 5", "OnHighLevelPlayers");
Expand Down Expand Up @@ -94,7 +122,8 @@ mysql_pquery(g_mysql, "SELECT * FROM rewards WHERE id = 1", "OnRewards");
|---|---|---|
| Threading | One worker thread per query | One worker thread per query |
| Callback order | FIFO (submission order) | First done, first dispatched |
| Typical use | SELECT chains that depend on order | UPDATE/INSERT and independent reads |
| Execution order | **Not ordered** — concurrent | **Not ordered** — concurrent |
| Typical use | Reads whose callbacks must arrive in order | UPDATE/INSERT and independent reads |
| Reordering cost | Yes — results buffer until the next sequence is available | None |

Both natives create one OS thread per query — the cost of a thread spawn is roughly the cost of one TCP round-trip, well below the cost of a real MySQL query.
Expand Down
65 changes: 65 additions & 0 deletions examples/12_no_callback.pwn
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 12_no_callback.pwn - non-blocking queries WITHOUT a callback (fire-and-forget).
//
// Non-blocking does not mean "write a callback for everything". Every native
// that takes a callback takes it with a default of "", and passing nothing
// runs the query and discards the result. That is one line, no forward, no
// public - and the server still never blocks.
//
// Use it for writes: UPDATE, INSERT, DELETE. You need a callback only to read
// a result back, because a value that arrives later has nowhere else to go.
// The two password natives are the exception: their callback is required.
//
// A failure is NOT silenced - it still reaches OnQueryError.
//
// Careful with ordering: queries submitted one after the other run
// CONCURRENTLY, each on its own connection. See 13 below for what to use when
// one statement depends on another.

#include <a_samp>
#include <mysql_samp>

#define MYSQL_HOST "127.0.0.1"
#define MYSQL_USER "samp"
#define MYSQL_PASSWORD "secret"
#define MYSQL_DATABASE "samp_server"

new g_MysqlConn = 0;

public OnGameModeInit()
{
g_MysqlConn = mysql_connect(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE);
return 1;
}

public OnPlayerDisconnect(playerid, reason)
{
new name[MAX_PLAYER_NAME];
GetPlayerName(playerid, name, sizeof(name));

new query[256];
mysql_format(g_MysqlConn, query, sizeof(query),
"UPDATE players SET last_seen = NOW() WHERE name = '%e'", name);

// No callback: the write is dispatched and the result discarded.
mysql_query(g_MysqlConn, query);
return 1;
}

public OnPlayerDeath(playerid, killerid, reason)
{
new query[128];
mysql_format(g_MysqlConn, query, sizeof(query),
"UPDATE players SET deaths = deaths + 1 WHERE id = %d", playerid);

// Independent writes have no reason to be ordered: mysql_pquery skips the
// FIFO buffering entirely. Also callback-free.
mysql_pquery(g_MysqlConn, query);
return 1;
}

// Errors still arrive, even with no callback on the query that failed.
public OnQueryError(errorid, const error[], const callback[], const query[], connId)
{
printf("[MySQL] error %d on '%s': %s", errorid, query, error);
return 1;
}
65 changes: 65 additions & 0 deletions examples/13_ordering.pwn
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 13_ordering.pwn - what is ordered, what is not, and what to use instead.
//
// mysql_query is described as FIFO. What that orders is the DELIVERY OF
// CALLBACKS, in submission order. It does NOT order execution: each query
// gets its own worker thread and its own pool connection as soon as it is
// submitted, so submitted statements run at the same time on the server.
//
// This matters the moment one statement depends on another.

#include <a_samp>
#include <mysql_samp>

#define MYSQL_HOST "127.0.0.1"
#define MYSQL_USER "samp"
#define MYSQL_PASSWORD "secret"
#define MYSQL_DATABASE "samp_server"

new g_MysqlConn = 0;

public OnGameModeInit()
{
g_MysqlConn = mysql_connect(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE);

// WRONG - these race. The INSERT usually fails with "table doesn't exist",
// because the CREATE is still running on another connection.
//
// mysql_query(g_MysqlConn, "CREATE TABLE stats (id INT, kills INT)");
// mysql_query(g_MysqlConn, "INSERT INTO stats VALUES (1, 0)");

// RIGHT for schema work: one file, statements run in order, one
// connection, still non-blocking, still no callback needed.
// The path is relative to the server root, not to scriptfiles/.
mysql_query_file(g_MysqlConn, "schema.sql");
return 1;
}

// RIGHT for writes that depend on each other: a transaction runs its steps in
// order on one connection, and rolls back if any of them fails.
StorePurchase(playerid, itemid, price)
{
new tx = mysql_transaction_new(g_MysqlConn);
if (tx == 0)
return 0;

new query[192];

mysql_format(g_MysqlConn, query, sizeof(query),
"UPDATE players SET money = money - %d WHERE id = %d", price, playerid);
mysql_transaction_add(tx, query);

mysql_format(g_MysqlConn, query, sizeof(query),
"INSERT INTO inventory (player_id, item_id) VALUES (%d, %d)", playerid, itemid);
mysql_transaction_add(tx, query);

// No callback here either: either both steps land, or neither does.
mysql_transaction_execute(tx);
return 1;
}

// RIGHT for a read that depends on a write: chain it in the write's callback.
public OnQueryError(errorid, const error[], const callback[], const query[], connId)
{
printf("[MySQL] error %d on '%s': %s", errorid, query, error);
return 1;
}
7 changes: 7 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

Runnable Pawn snippets showing how to use the plugin on **SA-MP** and **Open Multiplayer**. The plugin natives (`mysql_*`, `cache_*`, `orm_*`) and the `OnQueryError` forward are identical across both servers — every snippet here exercises only the plugin API.

> **Non-blocking does not mean a callback for everything.** Every native that takes a `callback` takes it with a
> default of `""`, so a write is one line and nothing else — see [`12_no_callback.pwn`](12_no_callback.pwn).
> The exception is the two password natives, whose callback is required. Most examples below pass a callback
> because they read a result back, not because it is mandatory.

> **Start with [`08_prepared_statements.pwn`](08_prepared_statements.pwn) for anything involving player input.** `mysql_format` (example 04) escapes values into the SQL text, which is correct only as long as the escaping matches the server's `sql_mode`. Prepared statements bind values server-side, so there is nothing to escape and nothing to get wrong.

| File | Topic |
Expand All @@ -17,6 +22,8 @@ Runnable Pawn snippets showing how to use the plugin on **SA-MP** and **Open Mul
| [`09_transactions.pwn`](09_transactions.pwn) | `mysql_transaction_*` — all-or-nothing batches |
| [`10_password_hashing.pwn`](10_password_hashing.pwn) | Argon2id: `mysql_hash_password` / `mysql_verify_password` |
| [`11_config_and_scripts.pwn`](11_config_and_scripts.pwn) | `mysql_connect_file`, `mysql_query_file`, multiple result sets |
| [`12_no_callback.pwn`](12_no_callback.pwn) | Fire-and-forget: non-blocking queries **without** a callback |
| [`13_ordering.pwn`](13_ordering.pwn) | What is ordered (callbacks) and what is not (execution), and what to use instead |

### Companion files

Expand Down
39 changes: 26 additions & 13 deletions include/mysql_samp.inc
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ native bool:mysql_log(log_level);
*
* @param connId connection id
* @param query the SQL
* @param callback callback to fire with the result, or "" for none
* @param callback callback to fire with the result. Optional: "" (the
* default) discards the result and fires nothing
* @param format types of the extra callback args ("d" int, "f" float, "s" string)
* @return true if submitted, false on a bad connection
*/
Expand All @@ -218,7 +219,8 @@ native bool:mysql_query(connId, const query[], const callback[] = "", const form
*
* @param connId connection id
* @param query the SQL
* @param callback callback to fire with the result, or "" for none
* @param callback callback to fire with the result. Optional: "" (the
* default) discards the result and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on a bad connection
*/
Expand Down Expand Up @@ -265,7 +267,8 @@ native mysql_format(connId, dest[], max_len, const format[], {Float,_}:...);
*
* @param connId connection id
* @param path path to the .sql file
* @param callback callback to fire with the last statement's result, or ""
* @param callback callback to fire with the last statement's result.
* Optional: "" (the default) discards it
* @param format types of the extra callback args
* @return true if submitted, false on a bad connection or unreadable file
*/
Expand Down Expand Up @@ -338,7 +341,8 @@ native bool:mysql_stmt_bind_null(stmtId);
* not match the number of `?` placeholders.
*
* @param stmtId statement id
* @param callback callback to fire with the result, or "" for none
* @param callback callback to fire with the result. Optional: "" (the
* default) discards the result and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on a bad id or arity mismatch
*/
Expand All @@ -348,7 +352,8 @@ native bool:mysql_stmt_execute(stmtId, const callback[] = "", const format[] = "
* Executes a statement on a worker thread, parallel: no ordering guarantee.
*
* @param stmtId statement id
* @param callback callback to fire with the result, or "" for none
* @param callback callback to fire with the result. Optional: "" (the
* default) discards the result and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on a bad id or arity mismatch
*/
Expand Down Expand Up @@ -593,7 +598,8 @@ native orm_errno(orm_id);
* SELECTs the row matching the key and fills the bound variables.
*
* @param orm_id ORM id
* @param callback callback to fire when done, or ""
* @param callback callback to fire when done. Optional: "" (the default)
* runs the operation and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on failure
*/
Expand All @@ -603,7 +609,8 @@ native bool:orm_select(orm_id, const callback[] = "", const format[] = "", {Floa
* UPDATEs the row matching the key with the bound variables.
*
* @param orm_id ORM id
* @param callback callback to fire when done, or ""
* @param callback callback to fire when done. Optional: "" (the default)
* runs the operation and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on failure
*/
Expand All @@ -613,7 +620,8 @@ native bool:orm_update(orm_id, const callback[] = "", const format[] = "", {Floa
* INSERTs a row from the bound variables.
*
* @param orm_id ORM id
* @param callback callback to fire when done, or ""
* @param callback callback to fire when done. Optional: "" (the default)
* runs the operation and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on failure
*/
Expand All @@ -623,7 +631,8 @@ native bool:orm_insert(orm_id, const callback[] = "", const format[] = "", {Floa
* DELETEs the row matching the key.
*
* @param orm_id ORM id
* @param callback callback to fire when done, or ""
* @param callback callback to fire when done. Optional: "" (the default)
* runs the operation and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on failure
*/
Expand All @@ -633,7 +642,8 @@ native bool:orm_delete(orm_id, const callback[] = "", const format[] = "", {Floa
* INSERTs when the key is empty, UPDATEs otherwise.
*
* @param orm_id ORM id
* @param callback callback to fire when done, or ""
* @param callback callback to fire when done. Optional: "" (the default)
* runs the operation and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on failure
*/
Expand Down Expand Up @@ -711,7 +721,8 @@ native bool:orm_setkey(orm_id, const column_name[]);
* Hashes a password with Argon2id, off the server thread.
*
* @param password the plaintext
* @param callback callback receiving the PHC hash as its first argument
* @param callback callback receiving the PHC hash as its first argument.
* REQUIRED - the hash is delivered only through it
* @param format types of the extra callback args
* @return true if queued, false if the callback is empty, the password exceeds
* 1 KiB, or the work queue is full
Expand All @@ -723,7 +734,8 @@ native bool:mysql_hash_password(const password[], const callback[], const format
*
* @param password the plaintext
* @param hash the stored PHC string
* @param callback callback receiving the bool result as its first argument
* @param callback callback receiving the bool result as its first
* argument. REQUIRED - the result arrives only through it
* @param format types of the extra callback args
* @return true if queued, false if the callback is empty, the password exceeds
* 1 KiB, or the work queue is full
Expand Down Expand Up @@ -772,7 +784,8 @@ native bool:mysql_transaction_add_stmt(txId, stmtId);
* afterwards). The callback receives the cache of the LAST step.
*
* @param txId transaction id
* @param callback callback to fire on commit, or ""
* @param callback callback to fire on commit. Optional: "" (the default)
* commits and fires nothing
* @param format types of the extra callback args
* @return true if submitted, false on a bad id or empty batch
*/
Expand Down
Loading
Loading