From 379f9731223f281d49b3598d8ccbcd25c1298e83 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:42:36 -0300 Subject: [PATCH 1/6] docs: pagina de natives gerada a partir dos includes Os includes sao o unico lugar onde assinatura e documentacao de uma native ficam lado a lado, e o build.rs ja os mantem em passo com o Rust. Escrever a mesma coisa a mao em Markdown seria uma terceira copia para esquecer, entao a pagina passa a ser gerada deles - o que o cargo doc faz para o Rust, para a superficie Pawn. `scripts/gen_natives_page.py` produz `docs/natives.md`, e um hook do MkDocs o roda a cada build. O arquivo nao vai para o repositorio: sem copia versionada nao ha copia velha. O `mkdocs.yml` passa a observar `include/` e o gerador para que `mkdocs serve` recarregue quando a fonte muda. Dois formatos de comentario sao entendidos: JavaDoc, que e o que os includes daqui usam, e pawndoc (``, ``, ``), que um projeto encontra em includes de terceiros. Seis fixtures cobrem os dois, mais o caso misto e o vazio, porque nada no repositorio notaria o suporte a pawndoc quebrando. `@example` num bloco vira bloco de codigo na pagina, e as tres natives que aceitam MYSQL_SYNC passam a trazer um. Antes, quem lesse a documentacao da native nao tinha como descobrir a forma bloqueante: o `@param callback` falava so do `""`. Duas natives de statement, que recusam o MYSQL_SYNC, passam a dizer isso explicitamente. Para o exemplo poder citar uma native, o build.rs agora reescreve nomes dentro de comentarios ao gerar o include no estilo open.mp - do nome mais longo para o mais curto, senao `mysql_query_file` viraria `MySQL_Query_file`. O mesmo exemplo le `mysql_query` num arquivo e `MySQL_Query` no outro. Na renderizacao, constantes e literais viram code span, para nao se confundirem com a prosa ao redor, e os titulos de secao perdem o qualificador entre parenteses que o include usa: "Query (non-blocking)" vira "Query", ja que cada entrada diz por si o que faz. O workflow de docs roda `--selftest` e `--check` antes do build, entao native sem bloco de documentacao reprova. --- .github/workflows/docs.yml | 5 + .gitignore | 3 + CHANGELOG.md | 4 + CONTRIBUTING.md | 19 ++ build.rs | 64 +++- docs/hooks/generate_natives.py | 32 ++ include/mysql_samp.inc | 30 +- include/mysql_samp.inc.in | 30 +- include/mysql_samp_omp.inc | 40 ++- mkdocs.yml | 10 + scripts/gen_natives_page.py | 536 +++++++++++++++++++++++++++++++++ 11 files changed, 750 insertions(+), 23 deletions(-) create mode 100644 docs/hooks/generate_natives.py create mode 100644 scripts/gen_natives_page.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0499f9e..fd5cd0d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,6 +49,11 @@ jobs: - name: Install MkDocs and theme run: pip install --require-hashes -r docs/requirements.txt + - name: Check the natives page generator + run: | + python3 scripts/gen_natives_page.py --selftest + python3 scripts/gen_natives_page.py --check + - name: Strict build (fails on warnings) run: mkdocs build --strict diff --git a/.gitignore b/.gitignore index 717e3f6..b9add6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ CLAUDE.md /dist /reference +/notes # MkDocs /site +# Built from the includes on every docs build; see scripts/gen_natives_page.py +/docs/natives.md /.venv __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8c97a..10ae55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,10 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n ### Documentation +- **A natives page built from the includes.** [`docs/natives.md`](https://nullsablex.github.io/mysql_samp/natives/) lists every native, the forward and the documented constants, each with its signature, its open.mp alias, its parameters and its return — generated by `scripts/gen_natives_page.py` on every docs build rather than written by hand, so it cannot drift from what the plugin declares. Two comment formats are read: JavaDoc, which the includes here use, and pawndoc (``, ``, ``), which a project is likely to meet in includes it pulls in. A `--selftest` covers both against fixtures, and `--check` fails the docs build if an entry has no documentation block. + +- **`@example` in a doc block becomes a code block on the page**, and the three natives that accept `MYSQL_SYNC` now carry one, so how to make a call blocking is visible where the native is documented instead of only on the queries page. Native names inside comments are rewritten when the open.mp include is generated, so the same example reads `mysql_query` in one file and `MySQL_Query` in the other. + - **The callback is optional, and the docs now say so where people read.** Nothing forces a callback on a write: `mysql_query(conn, "UPDATE …")` is one line. That was documented in a subsection at the bottom of one page while the opening sentence and all eleven examples implied the opposite, which is where the complaint that the plugin "makes you write callbacks for everything" came from. (#55) - **FIFO orders callbacks, not execution.** `mysql_query` dispatches callbacks in submission order, but each statement runs on its own connection, concurrently — a `CREATE TABLE` followed by an `INSERT` races and the insert fails. The comparison table recommended `mysql_query` for exactly that. It now points at `mysql_query_file` and transactions, the two things that actually serialise, and two examples were added for the callback-free and ordering cases. (#55) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9794595..f0aaab7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,25 @@ greps for errors reports success for a file that never compiled. in the signature. It is a cross-check by hand, not a replacement for the `.inc.in`, which carries the Pawn tags, the enums and the JavaDoc that the generated file has no way to know about. +- **The natives page is generated, not written.** `docs/natives.md` is built + from the includes by `scripts/gen_natives_page.py` on every docs build, so + it is not in the repository and editing it is pointless. What reaches that + page is the JavaDoc block in `include/mysql_samp.inc.in` - including + `@example`, which becomes a code block. Native names inside a comment are + rewritten for the open.mp include, so an example can name a native and still + read correctly in both files. + + ```bash + python3 scripts/gen_natives_page.py # write docs/natives.md + python3 scripts/gen_natives_page.py --check # fail if an entry has no doc block + python3 scripts/gen_natives_page.py --selftest # parser fixtures, JavaDoc and pawndoc + ``` + + Both comment formats are understood: JavaDoc (`@param`, `@return`) and + pawndoc (``, ``, ``). The includes here use + JavaDoc; pawndoc is covered because a project is likely to pull in includes + that use it. + - **Pawn sources are pure ASCII.** No accents, no typographic dashes, no curly quotes — the compiler reads bytes, and mojibake reaches the player instead of failing the build. Markdown is the exception, and the CI guard enforces the diff --git a/build.rs b/build.rs index 48e8ebd..b8c2c1e 100644 --- a/build.rs +++ b/build.rs @@ -110,13 +110,36 @@ fn generate_omp_inc(base_rendered: &str) { None => base_rendered, }; + // Every base name and the styled name it becomes, longest first so that + // `mysql_query_file` is rewritten before `mysql_query` can match its + // prefix. Needed because doc comments carry usage examples, and an + // example naming `mysql_query` would be wrong in a file where the native + // is called `MySQL_Query`. + let mut renames: Vec<(String, String)> = body + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("native ")?; + let open = rest.find('(')?; + let head = rest[..open].trim(); + let name = head.rsplit(':').next()?.trim(); + Some((name.to_string(), to_omp_name(name))) + }) + .collect(); + renames.sort_by_key(|(base, _)| std::cmp::Reverse(base.len())); + for line in body.lines() { let trimmed = line.trim(); let Some(rest) = trimmed.strip_prefix("native ") else { - // Not a native declaration - copy the line as-is (enum, forward, - // define, comment, blank). - out.push_str(line); + // Not a native declaration. Comments still need the names inside + // them rewritten; everything else is copied verbatim. + let is_comment = + trimmed.starts_with('*') || trimmed.starts_with("/*") || trimmed.starts_with("//"); + if is_comment { + out.push_str(&rewrite_names(line, &renames)); + } else { + out.push_str(line); + } out.push('\n'); continue; }; @@ -147,6 +170,41 @@ fn generate_omp_inc(base_rendered: &str) { } } +/// Replaces whole identifiers inside a line, leaving partial matches alone. +/// +/// A plain `str::replace` would turn `mysql_query_file` into +/// `MySQL_Query_file` when rewriting `mysql_query`, so each hit is only taken +/// when the characters around it cannot be part of an identifier. +fn rewrite_names(line: &str, renames: &[(String, String)]) -> String { + let mut out = line.to_string(); + + for (base, styled) in renames { + let mut from = 0; + while let Some(found) = out[from..].find(base.as_str()) { + let start = from + found; + let end = start + base.len(); + + let before_ok = out[..start] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric() && c != '_'); + let after_ok = out[end..] + .chars() + .next() + .is_none_or(|c| !c.is_alphanumeric() && c != '_'); + + if before_ok && after_ok { + out.replace_range(start..end, styled); + from = start + styled.len(); + } else { + from = end; + } + } + } + + out +} + /// `mysql_stmt_new` -> `MySQL_StmtNew`, `cache_get_row_count` -> /// `Cache_GetRowCount`, `orm_create` -> `ORM_Create`. /// diff --git a/docs/hooks/generate_natives.py b/docs/hooks/generate_natives.py new file mode 100644 index 0000000..7b7e858 --- /dev/null +++ b/docs/hooks/generate_natives.py @@ -0,0 +1,32 @@ +"""MkDocs hook: regenerates the natives page before the site is built. + +Keeping the page out of the repository and building it here is what makes +"generated from the includes" true rather than aspirational: there is no +committed copy that can be stale, and `mkdocs serve` picks up a change to the +includes on the next reload. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +GENERATOR = Path(__file__).resolve().parents[2] / "scripts/gen_natives_page.py" + + +def on_config(config): # noqa: ANN001, ANN201 - MkDocs hook signature + """Runs before the file tree is read, so the page exists in time for nav.""" + spec = importlib.util.spec_from_file_location("gen_natives_page", GENERATOR) + if spec is None or spec.loader is None: # pragma: no cover - broken checkout + raise RuntimeError(f"cannot load the natives generator at {GENERATOR}") + + module = importlib.util.module_from_spec(spec) + sys.modules["gen_natives_page"] = module + spec.loader.exec_module(module) + + page, undocumented = module.build() + module.OUTPUT.write_text(page, encoding="utf-8") + if undocumented: + print(f"WARNING - natives page: {len(undocumented)} entries have no doc block") + return config diff --git a/include/mysql_samp.inc b/include/mysql_samp.inc index 4bfbe0a..3872d6d 100644 --- a/include/mysql_samp.inc +++ b/include/mysql_samp.inc @@ -299,9 +299,15 @@ native bool:mysql_log(log_level); * @param connId connection id * @param query the SQL * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args ("d" int, "f" float, "s" string) * @return true if submitted, false on a bad connection + * + * @example Blocking, for start-up work. Reads on the next line, no callback: + * mysql_query(sql, "SELECT COUNT(*) AS n FROM accounts", MYSQL_SYNC); + * new total = cache_get_value_name_int(0, "n"); */ native bool:mysql_query(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -311,9 +317,15 @@ 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. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args * @return true if submitted, false on a bad connection + * + * @example Blocking. Same as the FIFO native in this mode, since a call that + * waits has nothing to run in parallel with: + * mysql_pquery(sql, "DELETE FROM sessions WHERE stale = 1", MYSQL_SYNC); */ native bool:mysql_pquery(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -359,9 +371,15 @@ 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. - * Optional: "" (the default) discards it + * Optional: "" (the default) discards it, or MYSQL_SYNC + * to run the whole file on the server thread, in order, + * before the call returns * @param format types of the extra callback args * @return true if submitted, false on a bad connection or unreadable file + * + * @example Blocking, which is what schema at start-up wants: the tables exist + * on the line after, with no callback chain and no race: + * mysql_query_file(sql, "schema.sql", MYSQL_SYNC); */ native bool:mysql_query_file(connId, const path[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -433,7 +451,8 @@ native bool:mysql_stmt_bind_null(stmtId); * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ @@ -444,7 +463,8 @@ native bool:mysql_stmt_execute(stmtId, const callback[] = "", const format[] = " * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ diff --git a/include/mysql_samp.inc.in b/include/mysql_samp.inc.in index 63f13fd..d8dcded 100644 --- a/include/mysql_samp.inc.in +++ b/include/mysql_samp.inc.in @@ -299,9 +299,15 @@ native bool:mysql_log(log_level); * @param connId connection id * @param query the SQL * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args ("d" int, "f" float, "s" string) * @return true if submitted, false on a bad connection + * + * @example Blocking, for start-up work. Reads on the next line, no callback: + * mysql_query(sql, "SELECT COUNT(*) AS n FROM accounts", MYSQL_SYNC); + * new total = cache_get_value_name_int(0, "n"); */ native bool:mysql_query(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -311,9 +317,15 @@ 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. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args * @return true if submitted, false on a bad connection + * + * @example Blocking. Same as the FIFO native in this mode, since a call that + * waits has nothing to run in parallel with: + * mysql_pquery(sql, "DELETE FROM sessions WHERE stale = 1", MYSQL_SYNC); */ native bool:mysql_pquery(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -359,9 +371,15 @@ 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. - * Optional: "" (the default) discards it + * Optional: "" (the default) discards it, or MYSQL_SYNC + * to run the whole file on the server thread, in order, + * before the call returns * @param format types of the extra callback args * @return true if submitted, false on a bad connection or unreadable file + * + * @example Blocking, which is what schema at start-up wants: the tables exist + * on the line after, with no callback chain and no race: + * mysql_query_file(sql, "schema.sql", MYSQL_SYNC); */ native bool:mysql_query_file(connId, const path[], const callback[] = "", const format[] = "", {Float,_}:...); @@ -433,7 +451,8 @@ native bool:mysql_stmt_bind_null(stmtId); * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ @@ -444,7 +463,8 @@ native bool:mysql_stmt_execute(stmtId, const callback[] = "", const format[] = " * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ diff --git a/include/mysql_samp_omp.inc b/include/mysql_samp_omp.inc index 6e718bf..5a9f3c2 100644 --- a/include/mysql_samp_omp.inc +++ b/include/mysql_samp_omp.inc @@ -62,9 +62,9 @@ enum { */ #define MYSQL_SYNC "!sync" -// Memory limits, for mysql_limit_set / mysql_limit_get +// Memory limits, for MySQL_LimitSet / MySQL_LimitGet enum { - // Saved caches held by cache_save at once. Default 1024. + // Saved caches held by Cache_Save at once. Default 1024. MYSQL_LIMIT_SAVED_CACHES = 0, // Rows a single result set may carry. Default 100000. MYSQL_LIMIT_RESULT_ROWS, @@ -305,9 +305,15 @@ native bool:MySQL_Log(log_level) = mysql_log; * @param connId connection id * @param query the SQL * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args ("d" int, "f" float, "s" string) * @return true if submitted, false on a bad connection + * + * @example Blocking, for start-up work. Reads on the next line, no callback: + * MySQL_Query(sql, "SELECT COUNT(*) AS n FROM accounts", MYSQL_SYNC); + * new total = Cache_GetValueNameInt(0, "n"); */ native bool:MySQL_Query(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...) = mysql_query; @@ -317,9 +323,15 @@ 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. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing, or + * MYSQL_SYNC to make THIS call blocking and read the + * result on the next line * @param format types of the extra callback args * @return true if submitted, false on a bad connection + * + * @example Blocking. Same as the FIFO native in this mode, since a call that + * waits has nothing to run in parallel with: + * MySQL_PQuery(sql, "DELETE FROM sessions WHERE stale = 1", MYSQL_SYNC); */ native bool:MySQL_PQuery(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...) = mysql_pquery; @@ -365,9 +377,15 @@ native MySQL_Format(connId, dest[], max_len, const format[], {Float,_}:...) = my * @param connId connection id * @param path path to the .sql file * @param callback callback to fire with the last statement's result. - * Optional: "" (the default) discards it + * Optional: "" (the default) discards it, or MYSQL_SYNC + * to run the whole file on the server thread, in order, + * before the call returns * @param format types of the extra callback args * @return true if submitted, false on a bad connection or unreadable file + * + * @example Blocking, which is what schema at start-up wants: the tables exist + * on the line after, with no callback chain and no race: + * MySQL_QueryFile(sql, "schema.sql", MYSQL_SYNC); */ native bool:MySQL_QueryFile(connId, const path[], const callback[] = "", const format[] = "", {Float,_}:...) = mysql_query_file; @@ -439,7 +457,8 @@ native bool:MySQL_StmtBindNull(stmtId) = mysql_stmt_bind_null; * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ @@ -450,7 +469,8 @@ native bool:MySQL_StmtExecute(stmtId, const callback[] = "", const format[] = "" * * @param stmtId statement id * @param callback callback to fire with the result. Optional: "" (the - * default) discards the result and fires nothing + * default) discards the result and fires nothing. + * MYSQL_SYNC is NOT accepted here * @param format types of the extra callback args * @return true if submitted, false on a bad id or arity mismatch */ @@ -629,7 +649,7 @@ native Cache_Save() = cache_save; /** * Frees a saved cache. * - * @param cache_id id from cache_save + * @param cache_id id from Cache_Save * @return true on success, false if the id is unknown */ native bool:Cache_Delete(cache_id) = cache_delete; @@ -637,7 +657,7 @@ native bool:Cache_Delete(cache_id) = cache_delete; /** * Makes a saved cache the active one. * - * @param cache_id id from cache_save + * @param cache_id id from Cache_Save * @return true on success, false if the id is unknown */ native bool:Cache_SetActive(cache_id) = cache_set_active; @@ -659,7 +679,7 @@ native bool:Cache_IsAnyActive() = cache_is_any_active; /** * Whether a saved-cache id is still valid. * - * @param cache_id id from cache_save + * @param cache_id id from Cache_Save * @return true if valid */ native bool:Cache_IsValid(cache_id) = cache_is_valid; diff --git a/mkdocs.yml b/mkdocs.yml index d0f3226..11b0ad3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,15 @@ theme: edit: material/pencil view: material/eye +hooks: + - docs/hooks/generate_natives.py + +# The natives page is built from these, so a change to either has to +# trigger a rebuild during `mkdocs serve`. +watch: + - include + - scripts/gen_natives_page.py + plugins: - search: lang: en @@ -95,6 +104,7 @@ nav: - Examples: migration-examples.md - Reference: - API reference: api-reference.md + - Natives: natives.md - Benchmark: benchmark.md extra: diff --git a/scripts/gen_natives_page.py b/scripts/gen_natives_page.py new file mode 100644 index 0000000..2af5276 --- /dev/null +++ b/scripts/gen_natives_page.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""Builds the natives reference page from the includes themselves. + +The includes are the only place where a native's signature and its +documentation sit side by side, and `build.rs` already keeps them in step with +the Rust. Writing the same thing again by hand in Markdown would be a third +copy to forget, so the page is generated from them instead - what `cargo doc` +does for Rust, for the Pawn surface. + +Two comment formats are understood, because a Pawn project is likely to see +both: + +* **JavaDoc** - `@param name text`, `@return text`. This is what the includes + here use, and what PawnPro renders on hover. +* **pawndoc** - the Pawn compiler's own XML tags: ``, ``, ``, ``. The open.mp standard library documents + itself this way. + +They can be mixed inside one block; whichever tags are present are used. + +Run from the repository root: + + python3 scripts/gen_natives_page.py # writes docs/natives.md + python3 scripts/gen_natives_page.py --check # reports gaps, writes nothing +""" + +from __future__ import annotations + +import html +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BASE_INCLUDE = ROOT / "include/mysql_samp.inc" +OMP_INCLUDE = ROOT / "include/mysql_samp_omp.inc" +OUTPUT = ROOT / "docs/natives.md" + + +@dataclass +class Doc: + """A parsed documentation block.""" + + summary: str = "" + remarks: list[str] = field(default_factory=list) + params: list[tuple[str, str]] = field(default_factory=list) + returns: str = "" + examples: list[tuple[str, list[str]]] = field(default_factory=list) + + def is_empty(self) -> bool: + return not ( + self.summary or self.remarks or self.params or self.returns or self.examples + ) + + +@dataclass +class Entry: + """One `native` or `forward`, with whatever documented it.""" + + kind: str + name: str + signature: str + doc: Doc + section: str + alias: str = "" + + +def strip_comment_markers(block: str) -> list[str]: + """Turns a raw comment block into its text lines. + + Handles `/** ... */` and runs of `///`, which both formats use. + """ + lines: list[str] = [] + for raw in block.splitlines(): + line = raw.strip() + if line.startswith("/**"): + line = line[3:] + elif line.startswith("///"): + line = line[3:] + elif line.startswith("*/"): + continue + elif line.startswith("*"): + line = line[1:] + line = line.removesuffix("*/").rstrip() + lines.append(line.strip()) + return lines + + +def parse_pawndoc(text: str, doc: Doc) -> bool: + """Reads the XML tags of pawndoc. Returns whether any were found.""" + found = False + + summary = re.search(r"(.*?)", text, re.S | re.I) + if summary: + doc.summary = " ".join(summary.group(1).split()) + found = True + + for match in re.finditer( + r'(.*?)', text, re.S | re.I + ): + doc.params.append((match.group(1), " ".join(match.group(2).split()))) + found = True + + returns = re.search(r"(.*?)", text, re.S | re.I) + if returns: + doc.returns = " ".join(returns.group(1).split()) + found = True + + for match in re.finditer(r"(.*?)", text, re.S | re.I): + doc.remarks.append(" ".join(match.group(1).split())) + found = True + + return found + + +def parse_javadoc(lines: list[str], doc: Doc) -> None: + """Reads `@param` / `@return`, and the free text above them as the summary.""" + summary: list[str] = [] + current: list[str] | None = None + target: tuple[str, list[str]] | None = None + + def flush() -> None: + nonlocal current, target + if target is not None and current is not None: + kind, name = target[0], target[1] + text = " ".join(" ".join(current).split()) + if kind == "param": + doc.params.append((name, text)) # type: ignore[arg-type] + else: + doc.returns = text + current, target = None, None + + example: tuple[list[str], list[str]] | None = None + + def flush_example() -> None: + nonlocal example + if example is not None: + caption = " ".join(" ".join(example[0]).split()) + doc.examples.append((caption, example[1])) + example = None + + for line in lines: + if example is not None: + stripped = line.strip() + if not stripped: + continue + # Inside an example, a line is code when it reads like a + # statement; anything else continues the caption. + if re.match(r"(new\s|[A-Za-z_][\w]*\s*\()", stripped): + example[1].append(stripped) + continue + if not example[1]: + example[0].append(stripped) + continue + flush_example() + + start = re.match(r"@example\s*(.*)", line) + if start: + flush() + example = ([start.group(1)], []) + continue + + param = re.match(r"@param\s+(\S+)\s*(.*)", line) + ret = re.match(r"@returns?\s*(.*)", line) + if param: + flush() + target, current = ("param", param.group(1)), [param.group(2)] # type: ignore[assignment] + elif ret: + flush() + target, current = ("return", ""), [ret.group(1)] # type: ignore[assignment] + elif current is not None: + if line: + current.append(line) + else: + flush() + elif line: + summary.append(line) + elif summary: + summary.append("") + flush() + flush_example() + + # The first paragraph is the summary; the rest is context worth keeping. + chunks: list[list[str]] = [[]] + for line in summary: + if line: + chunks[-1].append(line) + elif chunks[-1]: + chunks.append([]) + chunks = [c for c in chunks if c] + if chunks and not doc.summary: + doc.summary = " ".join(" ".join(chunks[0]).split()) + for extra in chunks[1:]: + doc.remarks.append(" ".join(" ".join(extra).split())) + + +def parse_doc(block: str) -> Doc: + doc = Doc() + lines = strip_comment_markers(block) + text = "\n".join(lines) + + # pawndoc first: when XML tags are present they are the structure, and any + # leftover prose is handled by the JavaDoc pass below. + had_xml = parse_pawndoc(text, doc) + without_xml = re.sub(r"<[^>]+>.*?]+>", "", text, flags=re.S) if had_xml else text + parse_javadoc([line.strip() for line in without_xml.splitlines()], doc) + return doc + + +def parse_include(path: Path) -> list[Entry]: + """Walks an include, pairing each declaration with the block above it.""" + source = path.read_text(encoding="utf-8") + entries: list[Entry] = [] + + section = "General" + pending: list[str] = [] + in_block = False + + for raw in source.splitlines(): + line = raw.strip() + + if in_block: + pending.append(raw) + if "*/" in line: + in_block = False + continue + + if line.startswith("/**"): + pending = [raw] + in_block = "*/" not in line + continue + + if line.startswith("///"): + pending.append(raw) + continue + + # A documented `#define` is part of the surface too: MYSQL_SYNC is how + # a call is made blocking, and a page that lists only natives leaves + # the reader with no way to find it. + define = re.match(r"#define\s+([A-Za-z_@][\w@]*)\s+(.+)$", line) + if define and pending: + entries.append( + Entry( + kind="define", + name=define.group(1), + signature=f"#define {define.group(1)} {define.group(2)}", + doc=parse_doc("\n".join(pending)), + section="Constants", + ) + ) + pending = [] + continue + + decl = re.match(r"(native|forward)\s+([A-Za-z_@][\w@]*:)?([A-Za-z_@][\w@]*)\s*\(", line) + if decl: + kind = decl.group(1) + entries.append( + Entry( + kind=kind, + name=decl.group(3), + signature=line.rstrip(";"), + doc=parse_doc("\n".join(pending)) if pending else Doc(), + # A forward is not part of whatever native section it + # happens to sit next to - in the include it lands right + # after the enum blocks. + section="Forwards" if kind == "forward" else section, + ) + ) + pending = [] + continue + + # A `// Section` line groups what follows, mirroring the include. + header = re.match(r"//\s+([A-Z][^/]*)$", line) + if header and not line.startswith("///"): + # The include qualifies some headers - "Query (non-blocking)" - + # which is noise once every entry states its own behaviour. The + # section is just the subject. + section = re.sub(r"\s*\(.*\)\s*$", "", header.group(1).strip()) + pending = [] + continue + + if not line: + continue + # Anything else (enum, define, brace) ends a dangling block. + pending = [] + + return entries + + +def parse_aliases(path: Path) -> dict[str, str]: + """Maps a base native to the name the open.mp include gives it.""" + aliases: dict[str, str] = {} + pattern = re.compile( + r"^native\s+(?:[A-Za-z_@][\w@]*:)?([A-Za-z_@][\w@]*)\s*\(.*?=\s*([A-Za-z_@][\w@]*)\s*;" + ) + for line in path.read_text(encoding="utf-8").splitlines(): + match = pattern.match(line.strip()) + if match: + aliases[match.group(2)] = match.group(1) + return aliases + + +# `MYSQL_SYNC`, `MYSQL_OPT_PORT`, `MYSQL_OK` - the names a reader is meant to +# type. In the include they are plain prose, which on a rendered page makes +# them indistinguishable from the sentence around them. +CONSTANT = re.compile(r"(? str: + """Marks up the parts of a description that are code, not prose.""" + text = CONSTANT.sub(r"`\1`", text) + return EMPTY_STRING.sub('`""`', text) + + +def anchor(heading: str) -> str: + """The id Python-Markdown's toc extension gives a heading. + + Lowercase, spaces to hyphens, and anything that is neither a word + character nor a hyphen dropped - underscores survive, which matters here + since every native has one. + """ + slug = heading.strip().lower() + slug = re.sub(r"[^\w\s-]", "", slug) + return re.sub(r"[\s]+", "-", slug) + + +def render(entries: list[Entry]) -> str: + out: list[str] = [] + out.append("# Natives\n") + out.append( + "Generated from [`include/mysql_samp.inc`]" + "(https://github.com/NullSablex/mysql_samp/blob/master/include/mysql_samp.inc.in) " + "on every docs build, so it cannot drift from what the plugin registers. " + "Every entry shows the snake_case name and, where one exists, the " + "`Prefix_PascalCase` alias that [``](installation.md#place-the-include) " + "declares for it — the two are the same native.\n" + ) + natives = len([e for e in entries if e.kind == "native"]) + forwards = len([e for e in entries if e.kind == "forward"]) + out.append(f"**{natives} natives**, **{forwards} forward** and the constants below.\n") + + by_section: dict[str, list[Entry]] = {} + for entry in entries: + by_section.setdefault(entry.section, []).append(entry) + + # Constants and forwards read as preamble to the natives, not as an + # afterthought at the end of a long page. + for first in ("Forwards", "Constants"): + if first in by_section: + by_section = {first: by_section.pop(first), **by_section} + + out.append("## Index\n") + for section, items in by_section.items(): + names = ", ".join(f"[`{e.name}`](#{anchor(e.name)})" for e in items) + out.append(f"- **{section}** — {names}") + out.append("") + + for section, items in by_section.items(): + out.append(f"## {section}\n") + for entry in items: + out.append(f"### {entry.name}\n") + out.append("```pawn") + # A declaration ends in a semicolon; a #define does not. + out.append(entry.signature if entry.kind == "define" else f"{entry.signature};") + if entry.alias: + out.append(f"// open.mp style: {entry.alias}") + out.append("```\n") + + if entry.doc.summary: + out.append(f"{emphasise(entry.doc.summary)}\n") + for remark in entry.doc.remarks: + out.append(f"{emphasise(remark)}\n") + if entry.doc.params: + out.append("| Parameter | Description |") + out.append("|---|---|") + for name, text in entry.doc.params: + out.append(f"| `{html.escape(name)}` | {emphasise(text)} |") + out.append("") + if entry.doc.returns: + out.append(f"**Returns:** {emphasise(entry.doc.returns)}\n") + + for caption, code in entry.doc.examples: + if caption: + out.append(f"{emphasise(caption)}\n") + out.append("```pawn") + out.extend(code) + out.append("```\n") + if entry.doc.is_empty(): + out.append("*No documentation block in the include.*\n") + + return "\n".join(out) + "\n" + + +def build() -> tuple[str, list[str]]: + entries = parse_include(BASE_INCLUDE) + aliases = parse_aliases(OMP_INCLUDE) + for entry in entries: + entry.alias = aliases.get(entry.name, "") + + undocumented = [e.name for e in entries if e.doc.is_empty()] + return render(entries), undocumented + + +SELFTEST_CASES: list[tuple[str, str, Doc]] = [ + ( + "javadoc", + """/** + * Opens a connection pool. + * + * Options are optional; port 3306 is the default. + * + * @param host hostname, IPv4, or IPv6 in brackets + * @param options an options handle, or 0 for defaults + * @return connection id (>= 1), or 0 on failure + */""", + Doc( + summary="Opens a connection pool.", + remarks=["Options are optional; port 3306 is the default."], + params=[ + ("host", "hostname, IPv4, or IPv6 in brackets"), + ("options", "an options handle, or 0 for defaults"), + ], + returns="connection id (>= 1), or 0 on failure", + ), + ), + ( + "pawndoc block comment", + """/** + * Opens a connection pool. + * hostname, IPv4, or IPv6 in brackets + * an options handle, or 0 for defaults + * connection id (>= 1), or 0 on failure + * Options are optional; port 3306 is the default. + */""", + Doc( + summary="Opens a connection pool.", + remarks=["Options are optional; port 3306 is the default."], + params=[ + ("host", "hostname, IPv4, or IPv6 in brackets"), + ("options", "an options handle, or 0 for defaults"), + ], + returns="connection id (>= 1), or 0 on failure", + ), + ), + ( + "pawndoc line comments", + """/// Closes a connection. + /// connection id + /// true on success""", + Doc( + summary="Closes a connection.", + params=[("connId", "connection id")], + returns="true on success", + ), + ), + ( + "pawndoc spanning several lines", + """/** + * + * Runs a statement and keeps the result. + * + * + * the SQL to run + * + */""", + Doc( + summary="Runs a statement and keeps the result.", + params=[("query", "the SQL to run")], + ), + ), + ( + "mixed: pawndoc summary, javadoc params", + """/** + * Hashes a password. + * + * @param password the plaintext + * @return true if queued + */""", + Doc( + summary="Hashes a password.", + params=[("password", "the plaintext")], + returns="true if queued", + ), + ), + ("no documentation", "", Doc()), +] + + +def selftest() -> int: + """Checks the parser against both comment formats. + + The includes here are written in JavaDoc, so nothing in the repository + would notice pawndoc support breaking. These fixtures do. + """ + failures = 0 + for name, block, expected in SELFTEST_CASES: + got = parse_doc(block) + for field_name in ("summary", "returns", "params", "remarks"): + want = getattr(expected, field_name) + have = getattr(got, field_name) + if want != have: + print(f"FAIL [{name}] {field_name}:\n expected {want!r}\n got {have!r}") + failures += 1 + if failures: + print(f"{failures} check(s) failed.") + return 1 + print(f"OK: {len(SELFTEST_CASES)} documentation fixtures parsed as expected.") + return 0 + + +def main() -> int: + if "--selftest" in sys.argv: + return selftest() + + page, undocumented = build() + + if "--check" in sys.argv: + if undocumented: + print("Natives with no documentation block:") + for name in undocumented: + print(f" {name}") + return 1 + print("OK: every native and forward carries a documentation block.") + return 0 + + OUTPUT.write_text(page, encoding="utf-8") + print(f"Wrote {OUTPUT.relative_to(ROOT)}") + if undocumented: + print(f"warning: {len(undocumented)} entries have no documentation block") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From b61a2f452b819289ec1d194bbe9e0332e7829c42 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:50:04 -0300 Subject: [PATCH 2/6] docs: a referencia da API passa a ser gerada, no lugar das tabelas a mao A `api-reference.md` era um conjunto de tabelas escritas a mao com nome, tipo e uma linha de descricao de cada native - a mesma informacao que o include ja carrega ao lado de cada declaracao, mantida em passo no braco. Era a copia que ainda faltava eliminar. Agora o gerador produz a pagina inteira na mesma URL: natives, o forward, as constantes documentadas e os enums com seus valores. O arquivo sai do versionamento e e reconstruido a cada build da doc. Os valores dos enums vinham digitados na pagina. Eles fazem parte do contrato - um script compara contra eles - e estavam sujeitos a sair de sincronia com o include em silencio; passam a ser lidos de la, junto com o comentario de cada entrada. A prosa que so existia na pagina foi para o include, que agora e a fonte unica: o `MYSQL_SAMP_VERSION` ganhou bloco de documentacao explicando que o build.rs o carimba a partir do Cargo.toml, e que compara-lo no arranque e como um gamemode percebe include esquecido depois de uma atualizacao. A pagina mantem a URL, entao nenhum link existente quebra, e a antiga `natives.md` deixa de existir - uma referencia so. --- .gitignore | 2 +- CHANGELOG.md | 2 +- CONTRIBUTING.md | 4 +- docs/api-reference.md | 256 --------------------------------- docs/hooks/generate_natives.py | 4 +- include/mysql_samp.inc | 7 + include/mysql_samp.inc.in | 7 + include/mysql_samp_omp.inc | 7 + mkdocs.yml | 1 - scripts/gen_natives_page.py | 98 +++++++++++-- 10 files changed, 116 insertions(+), 272 deletions(-) delete mode 100644 docs/api-reference.md diff --git a/.gitignore b/.gitignore index b9add6e..9524b66 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,6 @@ CLAUDE.md # MkDocs /site # Built from the includes on every docs build; see scripts/gen_natives_page.py -/docs/natives.md +/docs/api-reference.md /.venv __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ae55a..c98fb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,7 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n ### Documentation -- **A natives page built from the includes.** [`docs/natives.md`](https://nullsablex.github.io/mysql_samp/natives/) lists every native, the forward and the documented constants, each with its signature, its open.mp alias, its parameters and its return — generated by `scripts/gen_natives_page.py` on every docs build rather than written by hand, so it cannot drift from what the plugin declares. Two comment formats are read: JavaDoc, which the includes here use, and pawndoc (``, ``, ``), which a project is likely to meet in includes it pulls in. A `--selftest` covers both against fixtures, and `--check` fails the docs build if an entry has no documentation block. +- **The API reference is generated from the includes.** [`api-reference.md`](https://nullsablex.github.io/mysql_samp/api-reference/) was a hand-written set of tables listing every native, its type and a one-line description — the same information the includes already carry beside each declaration, kept in step by hand. It is now produced by `scripts/gen_natives_page.py` on every docs build: natives, the forward, the documented constants and the enumerations with their values, each with its signature, its open.mp alias, its parameters and its return. The page keeps its URL, and the hand-maintained copy is gone. Two comment formats are read: JavaDoc, which the includes here use, and pawndoc (``, ``, ``), which a project is likely to meet in includes it pulls in. A `--selftest` covers both against fixtures, and `--check` fails the docs build if an entry has no documentation block. - **`@example` in a doc block becomes a code block on the page**, and the three natives that accept `MYSQL_SYNC` now carry one, so how to make a call blocking is visible where the native is documented instead of only on the queries page. Native names inside comments are rewritten when the open.mp include is generated, so the same example reads `mysql_query` in one file and `MySQL_Query` in the other. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0aaab7..05028e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,7 +78,7 @@ greps for errors reports success for a file that never compiled. in the signature. It is a cross-check by hand, not a replacement for the `.inc.in`, which carries the Pawn tags, the enums and the JavaDoc that the generated file has no way to know about. -- **The natives page is generated, not written.** `docs/natives.md` is built +- **The API reference is generated, not written.** `docs/api-reference.md` is built from the includes by `scripts/gen_natives_page.py` on every docs build, so it is not in the repository and editing it is pointless. What reaches that page is the JavaDoc block in `include/mysql_samp.inc.in` - including @@ -87,7 +87,7 @@ greps for errors reports success for a file that never compiled. read correctly in both files. ```bash - python3 scripts/gen_natives_page.py # write docs/natives.md + python3 scripts/gen_natives_page.py # write docs/api-reference.md python3 scripts/gen_natives_page.py --check # fail if an entry has no doc block python3 scripts/gen_natives_page.py --selftest # parser fixtures, JavaDoc and pawndoc ``` diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index c5bdb50..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,256 +0,0 @@ -# API reference - -Full table of every native and forward exposed by `mysql_samp`. The plugin registers **79 natives** and **1 forward**. - -Source of truth: [`include/mysql_samp.inc.in`](https://github.com/NullSablex/mysql_samp/blob/master/include/mysql_samp.inc.in) and [`src/lib.rs`](https://github.com/NullSablex/mysql_samp/blob/master/src/lib.rs). - -Every name below is listed in its snake_case form, as declared by ``. The alternative include `` declares the exact same set under open.mp's `Prefix_PascalCase` convention — `mysql_connect` becomes `MySQL_Connect`, `cache_get_row_count` becomes `Cache_GetRowCount`, `orm_create` becomes `ORM_Create`. It is generated from the same template at build time and is aliases only, so this page describes both: translate the name, not the behaviour. See [Installation](installation.md#place-the-include). - -## Constants - -```pawn -#define MYSQL_SAMP_VERSION "X.Y.Z" -``` - -The literal is regenerated by `build.rs` from `CARGO_PKG_VERSION` on every build, so the value shipped in both includes always tracks the version declared in [`Cargo.toml`](https://github.com/NullSablex/mysql_samp/blob/master/Cargo.toml). This page does not hardcode the current number on purpose — check `Cargo.toml` or the include itself. - -```pawn -#define MYSQL_SYNC "!sync" -``` - -Written where a callback name goes, it makes that one call blocking — see -[Queries](queries.md#mysql_sync--making-one-call-block-on-purpose). Accepted by -`mysql_query`, `mysql_pquery` and `mysql_query_file`; every other native that -takes a callback refuses it out loud rather than mistaking it for one. - -### Connection options - -| Constant | Value | -|---|---| -| `MYSQL_OPT_PORT` | 0 | -| `MYSQL_OPT_SSL` | 1 | -| `MYSQL_OPT_SSL_CA` | 2 | -| `MYSQL_OPT_CONNECT_TIMEOUT` | 3 | -| `MYSQL_OPT_AUTO_RECONNECT` | 4 | - -### Plugin error codes - -| Constant | Value | -|---|---| -| `MYSQL_OK` | 0 | -| `MYSQL_ERROR_CONNECTION_FAILED` | 1 | -| `MYSQL_ERROR_INVALID_OPTIONS` | 2 | -| `MYSQL_ERROR_INVALID_CONNECTION` | 3 | -| `MYSQL_ERROR_PING_FAILED` | 4 | -| `MYSQL_ERROR_QUERY_FAILED` | 5 | -| `MYSQL_ERROR_NO_CACHE_ACTIVE` | 6 | -| `MYSQL_ERROR_INVALID_ORM` | 7 | -| `MYSQL_ERROR_ORM_KEY_NOT_SET` | 8 | - -### Log levels - -| Constant | Value | -|---|---| -| `MYSQL_LOG_NONE` | 0 | -| `MYSQL_LOG_ERROR` | 1 | -| `MYSQL_LOG_WARNING` | 2 | -| `MYSQL_LOG_INFO` | 3 | -| `MYSQL_LOG_ALL` | 4 | - -### ORM error codes - -| Constant | Value | -|---|---| -| `ORM_OK` | 0 | -| `ORM_NO_DATA` | 1 | - -## Natives - -### Options (3) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_options_new()` | `int` | New options handle (`>= 1`) | -| `mysql_options_set_int(handle, option, value)` | `bool` | `false` if handle invalid, option unknown, or value out of range | -| `mysql_options_set_str(handle, option, const value[])` | `bool` | `false` if handle invalid or option not a string option | - -### Connection (3) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_connect(const host[], const user[], const password[], const database[], options = 0)` | `int` | Connection id (`>= 1`) or `0` on failure | -| `mysql_close(connId)` | `bool` | `false` if `connId` does not exist | -| `mysql_status(connId, dest[], max_len = sizeof(dest))` | `bool` | Writes a subset of `SHOW GLOBAL STATUS` into `dest` | -| `mysql_tls_active(connId)` | `bool` | Whether the session is encrypted, as the handshake settled it | -| `mysql_tls_cipher(connId, dest[], max_len = sizeof(dest))` | `bool` | Writes the negotiated cipher suite; empty when unencrypted | -| `mysql_limit_set(limit, value)` | `bool` | Raises or lowers a memory cap (`MYSQL_LIMIT_*`) | -| `mysql_limit_get(limit)` | `int` | Reads a memory cap | - -### Error (2) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_errno(connId = 0)` | `int` | One of `MYSQL_*` codes. `0` reads global state | -| `mysql_error(connId, dest[], max_len = sizeof(dest))` | `bool` | Always `true` (writes the stored message; empty if no error) | - -### Charset (2) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_set_charset(connId, const charset[])` | `bool` | Runs `SET NAMES ''` | -| `mysql_get_charset(connId, dest[], max_len = sizeof(dest))` | `bool` | Reads `@@character_set_connection` | - -### Utility (2) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_unprocessed_queries()` | `int` | In-flight + pending-ordered count | -| `mysql_log(log_level)` | `bool` | Atomic; always `true` | - -### Queries (5) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_query(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | FIFO-ordered threaded query | -| `mysql_pquery(connId, const query[], const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Parallel threaded query | -| `mysql_tick()` | `bool` | Drains the dispatch queue manually (back-compat only) | -| `mysql_escape_string(const src[], dest[], max_len = sizeof(dest), connId = 0)` | `bool` | `connId` selects the escaping rules for the server's `sql_mode`; pass it whenever the server may run `NO_BACKSLASH_ESCAPES` | -| `mysql_query_file(connId, const path[], const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Runs a `.sql` file's statements in order. Not transactional | -| `mysql_format(connId, dest[], max_len, const format[], {Float,_}:...)` | `int` | Bytes written into `dest` (truncated to `max_len - 1` at a UTF-8 boundary) | - -### Prepared statements (8) - -Values are bound server-side over the binary protocol and never enter the SQL text. Prefer these over `mysql_format` for anything carrying player input. - -| Native | Returns | Notes | -|---|---|---| -| `mysql_stmt_new(connId, const query[])` | `int` | Statement ID, or `0` on failure. `?` placeholders | -| `mysql_stmt_bind_int(stmtId, value)` | `bool` | | -| `mysql_stmt_bind_float(stmtId, Float:value)` | `bool` | | -| `mysql_stmt_bind_str(stmtId, const value[])` | `bool` | | -| `mysql_stmt_bind_null(stmtId)` | `bool` | Binds SQL `NULL` | -| `mysql_stmt_reset(stmtId)` | `bool` | Drops bound values, keeps the statement | -| `mysql_stmt_execute(stmtId, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Non-blocking, FIFO. Fails if bound values ≠ placeholders | -| `mysql_stmt_pexecute(stmtId, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Parallel counterpart, mirroring `mysql_pquery` | -| `mysql_stmt_close(stmtId)` | `bool` | | - -### Transactions (5) - -| Native | Returns | Notes | -|---|---|---| -| `mysql_transaction_new(connId)` | `int` | Transaction ID, or `0` on failure | -| `mysql_transaction_add(txId, const query[])` | `bool` | Plain SQL step | -| `mysql_transaction_add_stmt(txId, stmtId)` | `bool` | Copies a prepared statement with its bound values | -| `mysql_transaction_execute(txId, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Atomic batch; **consumes the handle**; callback gets the last step's cache | -| `mysql_transaction_destroy(txId)` | `bool` | Discards a batch that was never executed | - -### Password hashing (2) - -Both run Argon2id on a bounded worker pool. The result is the **first** callback argument, followed by the extras described by `format`. - -| Native | Returns | Notes | -|---|---|---| -| `mysql_hash_password(const password[], const callback[], const format[] = "", {Float,_}:...)` | `bool` | `false` if the queue is full or the password exceeds 1 KiB. Callback receives the PHC hash string | -| `mysql_verify_password(const password[], const hash[], const callback[], const format[] = "", {Float,_}:...)` | `bool` | Callback receives a `bool` | - -#### `mysql_format` specifiers - -| Specifier | Type | Behavior | -|---|---|---| -| `%d`, `%i` | int | Decimal | -| `%f` | float | `{:.4}` | -| `%s`, `%e` | string | Escaped | -| `%r` | string | Raw, **not** escaped | -| `%%` | literal | `%` | - -#### Callback format characters - -| Char | Pawn type | -|---|---| -| `d`, `i` | int | -| `f` | float | -| `s` | string | - -Anything else is counted as an unknown specifier and logged. - -### Cache — dimensions and values (12) - -| Native | Returns | Notes | -|---|---|---| -| `cache_get_row_count()` | `int` | `-1` if no active cache | -| `cache_get_field_count()` | `int` | `-1` if no active cache | -| `cache_get_field_name(field_idx, dest[], max_len = sizeof(dest))` | `bool` | `false` if `field_idx` is negative or out of range | -| `cache_get_field_type(field_idx)` | `int` | Raw `mysql` `ColumnType` byte, or `-1` | -| `cache_get_value_index(row, col, dest[], max_len = sizeof(dest))` | `bool` | String cell by index | -| `cache_get_value_index_int(row, col)` | `int` | Parsed int, `0` on miss | -| `cache_get_value_index_float(row, col)` | `Float` | Parsed float, `0.0` on miss | -| `cache_get_value_name(row, const field_name[], dest[], max_len = sizeof(dest))` | `bool` | Column name lookup is case-insensitive | -| `cache_get_value_name_int(row, const field_name[])` | `int` | Parsed int, `0` on miss | -| `cache_get_value_name_float(row, const field_name[])` | `Float` | Parsed float, `0.0` on miss | -| `cache_is_value_index_null(row, col)` | `bool` | `true` for NULL **and** for out-of-range | -| `cache_is_value_name_null(row, const field_name[])` | `bool` | `true` for NULL **and** for unknown column | - -### Cache — write-result and query metadata (5) - -| Native | Returns | Notes | -|---|---|---| -| `cache_affected_rows()` | `int` | `-1` if no active cache | -| `cache_insert_id()` | `int` | `-1` if no active cache | -| `cache_warning_count()` | `int` | `-1` if no active cache | -| `cache_get_query_exec_time()` | `int` | Milliseconds, `-1` if no active cache | -| `cache_get_query_string(dest[], max_len = sizeof(dest))` | `bool` | The exact SQL sent to the server | - -### Cache — persistence (6) - -| Native | Returns | Notes | -|---|---|---| -| `cache_save()` | `int` | Saved id (`>= 1`) or `0` if no active cache or limit (1024) hit | -| `cache_delete(cache_id)` | `bool` | Also clears the manual override if the deleted id was active | -| `cache_set_active(cache_id)` | `bool` | `false` if `cache_id` does not exist | -| `cache_unset_active()` | `bool` | `false` if there is no manual override | -| `cache_is_any_active()` | `bool` | Stack top **or** manual override | -| `cache_is_valid(cache_id)` | `bool` | Saved entry exists | - -### ORM (15) - -| Native | Returns | Notes | -|---|---|---| -| `orm_create(const table[], connId)` | `int` | ORM id (`>= 1`) or `0` if `connId` invalid | -| `orm_destroy(orm_id)` | `bool` | `false` if `orm_id` does not exist | -| `orm_errno(orm_id)` | `int` | `ORM_OK` or `ORM_NO_DATA`. `-1` if `orm_id` invalid. Only updated by `orm_apply_cache` | -| `orm_select(orm_id, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Threaded SELECT | -| `orm_update(orm_id, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Threaded UPDATE | -| `orm_insert(orm_id, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Threaded INSERT | -| `orm_delete(orm_id, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | Threaded DELETE | -| `orm_save(orm_id, const callback[] = "", const format[] = "", {Float,_}:...)` | `bool` | INSERT if key is empty (`0`, `0.0`, `""`), UPDATE otherwise | -| `orm_apply_cache(orm_id, row = 0)` | `bool` | Sets `orm_errno` to `ORM_NO_DATA` if `row` is invalid | -| `orm_addvar_int(orm_id, &var, const column_name[])` | `bool` | Stores the AMX address of `var` | -| `orm_addvar_float(orm_id, &Float:var, const column_name[])` | `bool` | Stores the AMX address of `var` | -| `orm_addvar_string(orm_id, var[], var_max_len, const column_name[])` | `bool` | `1 <= var_max_len <= 4096` | -| `orm_delvar(orm_id, const column_name[])` | `bool` | `false` if no binding matched | -| `orm_clear_vars(orm_id)` | `bool` | Always `true` if `orm_id` exists | -| `orm_setkey(orm_id, const column_name[])` | `bool` | Always `true` if `orm_id` exists | - -## Forwards (1) - -| Forward | Fired when | -|---|---| -| `OnQueryError(errorid, const error[], const callback[], const query[], connId)` | A threaded query (`mysql_query` / `mysql_pquery` and the ORM CRUD natives that wrap them) failed | - -## Counts - -| Category | Count | -|---|---| -| Options | 3 | -| Connection | 4 | -| Error | 2 | -| Charset | 2 | -| Utility | 2 | -| Queries | 6 | -| Prepared statements | 9 | -| Transactions | 5 | -| Password hashing | 2 | -| Cache | 25 | -| ORM | 15 | -| **Total natives** | **75** | -| Forwards | 1 | diff --git a/docs/hooks/generate_natives.py b/docs/hooks/generate_natives.py index 7b7e858..53319e2 100644 --- a/docs/hooks/generate_natives.py +++ b/docs/hooks/generate_natives.py @@ -1,4 +1,4 @@ -"""MkDocs hook: regenerates the natives page before the site is built. +"""MkDocs hook: regenerates the API reference before the site is built. Keeping the page out of the repository and building it here is what makes "generated from the includes" true rather than aspirational: there is no @@ -28,5 +28,5 @@ def on_config(config): # noqa: ANN001, ANN201 - MkDocs hook signature page, undocumented = module.build() module.OUTPUT.write_text(page, encoding="utf-8") if undocumented: - print(f"WARNING - natives page: {len(undocumented)} entries have no doc block") + print(f"WARNING - API reference: {len(undocumented)} entries have no doc block") return config diff --git a/include/mysql_samp.inc b/include/mysql_samp.inc index 3872d6d..1a721fd 100644 --- a/include/mysql_samp.inc +++ b/include/mysql_samp.inc @@ -10,6 +10,13 @@ #endif #define _mysql_samp_included +/** + * The plugin version this include was generated for. + * + * build.rs stamps it from Cargo.toml on every build, so the value in the + * include always matches the binary it shipped with. Comparing it at start-up + * is how a gamemode catches an include left behind by an upgrade. + */ #define MYSQL_SAMP_VERSION "1.4.0" // Connection options diff --git a/include/mysql_samp.inc.in b/include/mysql_samp.inc.in index d8dcded..6edd4fa 100644 --- a/include/mysql_samp.inc.in +++ b/include/mysql_samp.inc.in @@ -10,6 +10,13 @@ #endif #define _mysql_samp_included +/** + * The plugin version this include was generated for. + * + * build.rs stamps it from Cargo.toml on every build, so the value in the + * include always matches the binary it shipped with. Comparing it at start-up + * is how a gamemode catches an include left behind by an upgrade. + */ #define MYSQL_SAMP_VERSION "{{VERSION}}" // Connection options diff --git a/include/mysql_samp_omp.inc b/include/mysql_samp_omp.inc index 5a9f3c2..0138ca5 100644 --- a/include/mysql_samp_omp.inc +++ b/include/mysql_samp_omp.inc @@ -16,6 +16,13 @@ #define _mysql_samp_omp_included +/** + * The plugin version this include was generated for. + * + * build.rs stamps it from Cargo.toml on every build, so the value in the + * include always matches the binary it shipped with. Comparing it at start-up + * is how a gamemode catches an include left behind by an upgrade. + */ #define MYSQL_SAMP_VERSION "1.4.0" // Connection options diff --git a/mkdocs.yml b/mkdocs.yml index 11b0ad3..50390d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,7 +104,6 @@ nav: - Examples: migration-examples.md - Reference: - API reference: api-reference.md - - Natives: natives.md - Benchmark: benchmark.md extra: diff --git a/scripts/gen_natives_page.py b/scripts/gen_natives_page.py index 2af5276..feb3b8f 100644 --- a/scripts/gen_natives_page.py +++ b/scripts/gen_natives_page.py @@ -35,7 +35,7 @@ ROOT = Path(__file__).resolve().parents[1] BASE_INCLUDE = ROOT / "include/mysql_samp.inc" OMP_INCLUDE = ROOT / "include/mysql_samp_omp.inc" -OUTPUT = ROOT / "docs/natives.md" +OUTPUT = ROOT / "docs/api-reference.md" @dataclass @@ -54,6 +54,14 @@ def is_empty(self) -> bool: ) +@dataclass +class EnumBlock: + """One `enum { ... }` and the constants inside it.""" + + title: str + values: list[tuple[str, int, str]] = field(default_factory=list) + + @dataclass class Entry: """One `native` or `forward`, with whatever documented it.""" @@ -208,6 +216,52 @@ def parse_doc(block: str) -> Doc: return doc +def parse_enums(source: str) -> list[EnumBlock]: + """Reads the `enum { ... }` blocks, values and all. + + The values are part of the contract - a script may compare against them - + and the include is where they are defined, so writing them out by hand in + Markdown means maintaining the same numbers twice. + """ + blocks: list[EnumBlock] = [] + lines = source.splitlines() + + for index, raw in enumerate(lines): + if raw.strip() != "enum {": + continue + + # The `// Title` line above the block names it. + title = "Constants" + for back in range(index - 1, max(index - 3, -1), -1): + header = re.match(r"//\s+(.+)$", lines[back].strip()) + if header: + title = header.group(1).strip() + break + + block = EnumBlock(title=title) + nxt = 0 + comment: list[str] = [] + + for line in lines[index + 1 :]: + text = line.strip() + if text.startswith("}"): + break + if text.startswith("//"): + comment.append(text[2:].strip()) + continue + entry = re.match(r"([A-Z][A-Z0-9_]*)\s*(?:=\s*(-?\d+))?\s*,?$", text) + if entry: + value = int(entry.group(2)) if entry.group(2) is not None else nxt + nxt = value + 1 + block.values.append((entry.group(1), value, " ".join(comment))) + comment = [] + + if block.values: + blocks.append(block) + + return blocks + + def parse_include(path: Path) -> list[Entry]: """Walks an include, pairing each declaration with the block above it.""" source = path.read_text(encoding="utf-8") @@ -326,16 +380,23 @@ def anchor(heading: str) -> str: return re.sub(r"[\s]+", "-", slug) -def render(entries: list[Entry]) -> str: +def render(entries: list[Entry], enums: list[EnumBlock]) -> str: out: list[str] = [] - out.append("# Natives\n") + out.append("# API reference\n") out.append( - "Generated from [`include/mysql_samp.inc`]" + "Every native, forward and constant the plugin exposes, generated from " + "[`include/mysql_samp.inc.in`]" "(https://github.com/NullSablex/mysql_samp/blob/master/include/mysql_samp.inc.in) " - "on every docs build, so it cannot drift from what the plugin registers. " - "Every entry shows the snake_case name and, where one exists, the " - "`Prefix_PascalCase` alias that [``](installation.md#place-the-include) " - "declares for it — the two are the same native.\n" + "on every docs build. Nothing here is written by hand, so it cannot " + "drift from what the plugin declares.\n" + ) + out.append( + "Names are listed in the snake_case form that `` declares. " + "The alternative include " + "[``](installation.md#place-the-include) declares the " + "same set under open.mp's `Prefix_PascalCase` convention, shown beside " + "each signature — aliases only, so the behaviour described here is the " + "behaviour of both.\n" ) natives = len([e for e in entries if e.kind == "native"]) forwards = len([e for e in entries if e.kind == "forward"]) @@ -351,6 +412,24 @@ def render(entries: list[Entry]) -> str: if first in by_section: by_section = {first: by_section.pop(first), **by_section} + if enums: + out.append("## Enumerations\n") + out.append( + "Values are part of the contract — a script may compare against " + "them — so they are read from the include rather than copied here.\n" + ) + for block in enums: + out.append(f"### {re.sub(r',.*$', '', block.title)}\n") + has_notes = any(note for _, _, note in block.values) + out.append("| Constant | Value | Notes |" if has_notes else "| Constant | Value |") + out.append("|---|---|---|" if has_notes else "|---|---|") + for name, value, note in block.values: + row = f"| `{name}` | {value} |" + if has_notes: + row += f" {emphasise(note)} |" + out.append(row) + out.append("") + out.append("## Index\n") for section, items in by_section.items(): names = ", ".join(f"[`{e.name}`](#{anchor(e.name)})" for e in items) @@ -395,12 +474,13 @@ def render(entries: list[Entry]) -> str: def build() -> tuple[str, list[str]]: entries = parse_include(BASE_INCLUDE) + enums = parse_enums(BASE_INCLUDE.read_text(encoding="utf-8")) aliases = parse_aliases(OMP_INCLUDE) for entry in entries: entry.alias = aliases.get(entry.name, "") undocumented = [e.name for e in entries if e.doc.is_empty()] - return render(entries), undocumented + return render(entries, enums), undocumented SELFTEST_CASES: list[tuple[str, str, Doc]] = [ From 4722cad54f2eec9dc1a1e0d2dfb0f19b2bfe542c Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:55:13 -0300 Subject: [PATCH 3/6] docs: tira o alias open.mp de dentro do bloco de codigo O nome do estilo open.mp aparecia como comentario dentro do bloco Pawn, o que o faz parecer uma linha a ser digitada. Ele nao e codigo: e o outro nome da mesma native. Passa a ser uma linha propria abaixo da assinatura. --- scripts/gen_natives_page.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/gen_natives_page.py b/scripts/gen_natives_page.py index feb3b8f..4d32c3c 100644 --- a/scripts/gen_natives_page.py +++ b/scripts/gen_natives_page.py @@ -443,9 +443,11 @@ def render(entries: list[Entry], enums: list[EnumBlock]) -> str: out.append("```pawn") # A declaration ends in a semicolon; a #define does not. out.append(entry.signature if entry.kind == "define" else f"{entry.signature};") - if entry.alias: - out.append(f"// open.mp style: {entry.alias}") out.append("```\n") + if entry.alias: + # Outside the block on purpose: the alias is the other name for + # the same native, not a line anyone types. + out.append(f"In ``: `{entry.alias}`\n") if entry.doc.summary: out.append(f"{emphasise(entry.doc.summary)}\n") From c93115f2899917b6bd00cd8bc433666a3cbae112 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:59:44 -0300 Subject: [PATCH 4/6] docs: completa o changelog com as mudancas de include deste ciclo Faltavam duas, pequenas mas visiveis para quem usa: as duas natives de prepared statement agora dizem que nao aceitam MYSQL_SYNC em vez de calar, e o MYSQL_SAMP_VERSION ganhou bloco de documentacao. As duas aparecem tanto na pagina quanto no hover do editor. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98fb41..ba995e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,8 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n - **`@example` in a doc block becomes a code block on the page**, and the three natives that accept `MYSQL_SYNC` now carry one, so how to make a call blocking is visible where the native is documented instead of only on the queries page. Native names inside comments are rewritten when the open.mp include is generated, so the same example reads `mysql_query` in one file and `MySQL_Query` in the other. +- **The includes say more where it counts.** The two prepared-statement natives state that they do not accept `MYSQL_SYNC` rather than leaving it unsaid, and `MYSQL_SAMP_VERSION` carries a doc block explaining that `build.rs` stamps it from `Cargo.toml` and that comparing it at start-up is how a gamemode catches an include left behind by an upgrade. Both reach an editor's hover as well as the page. + - **The callback is optional, and the docs now say so where people read.** Nothing forces a callback on a write: `mysql_query(conn, "UPDATE …")` is one line. That was documented in a subsection at the bottom of one page while the opening sentence and all eleven examples implied the opposite, which is where the complaint that the plugin "makes you write callbacks for everything" came from. (#55) - **FIFO orders callbacks, not execution.** `mysql_query` dispatches callbacks in submission order, but each statement runs on its own connection, concurrently — a `CREATE TABLE` followed by an `INSERT` races and the insert fails. The comparison table recommended `mysql_query` for exactly that. It now points at `mysql_query_file` and transactions, the two things that actually serialise, and two examples were added for the callback-free and ordering cases. (#55) From cab3c50ad07ba8c3435e0d0db08a762f504e9a9f Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:06:02 -0300 Subject: [PATCH 5/6] docs: icone de banco no lugar do padrao do tema O site herdava a nuvem do Material no cabecalho e na aba do navegador. Um plugin de MySQL merece o icone que diz isso: o logo passa a ser `material/database` e o favicon o mesmo desenho, copiado do tema para `docs/assets/favicon.svg` - SVG, entao escala em qualquer densidade de tela e nao precisa de varios tamanhos. --- docs/assets/favicon.svg | 1 + mkdocs.yml | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 docs/assets/favicon.svg diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000..b583e2a --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 50390d5..2f0eb2c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,7 +43,11 @@ theme: toggle: icon: material/weather-sunny name: Light mode + # A database icon in the header and the browser tab, in place of the + # theme's default cloud - this documents a MySQL plugin. + favicon: assets/favicon.svg icon: + logo: material/database repo: fontawesome/brands/github edit: material/pencil view: material/eye From 187275272ca79d6e1cc6ab2daded777e883e98e9 Mon Sep 17 00:00:00 2001 From: NullSablex <244216261+NullSablex@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:09:29 -0300 Subject: [PATCH 6/6] docs: fecha o changelog da 1.4.0 com o que faltava Duas lacunas encontradas conferindo a secao commit a commit: - o icone de banco no site, desta rodada; - a rodada de bumps de actions e do pip, que a 1.3.0 registrava numa linha propria e aqui estava so implicita no "large round of transitive updates". Com isso a secao cobre as 25 alteracoes desde a v1.3.0. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba995e1..58d7b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n - **rust-samp moves from a git tag to crates.io** (`version = "3.5.0"`). This also clears a long-standing oddity: the `v3.5.0` tag declared `version = "3.4.0"` in its own manifest, so the lockfile recorded 3.4.0 pointing at the v3.5.0 tag. The SDK's new `mainthread` module is **not** adopted yet: its `post` takes a closure with no parameters and there is no route to plugin state from a worker thread, so the plugin's own `mpsc` channel stays until `post_with` / `post_with_amx` land. - **`mysql` 28.0.0 → 28.0.2**, plus a large round of transitive updates. (#38, #45, and the Dependabot group) +- Routine dependency and action bumps: the `github-actions` group in three rounds, `pymdown-extensions` in the docs requirements, and the cargo group — `lru`, `sha1`, `num-bigint`, `wasip2`, `windows-sys` and `derive_utils` among them. (#39, #40, #44–#54) ### CI / tooling @@ -67,6 +68,8 @@ Built on rust-samp v3.5.0, now from crates.io. Additive on the Pawn side: four n - **`@example` in a doc block becomes a code block on the page**, and the three natives that accept `MYSQL_SYNC` now carry one, so how to make a call blocking is visible where the native is documented instead of only on the queries page. Native names inside comments are rewritten when the open.mp include is generated, so the same example reads `mysql_query` in one file and `MySQL_Query` in the other. +- **The documentation site carries a database icon** in the header and in the browser tab, in place of the theme's default cloud. The SVG is committed rather than pulled from the theme at build time, so a theme update cannot change it underneath. + - **The includes say more where it counts.** The two prepared-statement natives state that they do not accept `MYSQL_SYNC` rather than leaving it unsaid, and `MYSQL_SAMP_VERSION` carries a doc block explaining that `build.rs` stamps it from `Cargo.toml` and that comparing it at start-up is how a gamemode catches an include left behind by an upgrade. Both reach an editor's hover as well as the page. - **The callback is optional, and the docs now say so where people read.** Nothing forces a callback on a write: `mysql_query(conn, "UPDATE …")` is one line. That was documented in a subsection at the bottom of one page while the opening sentence and all eleven examples implied the opposite, which is where the complaint that the plugin "makes you write callbacks for everything" came from. (#55)