// STRYKE-MYSQL — MYSQL / MARIADB CLIENT FOR STRYKE // OPT-IN PACKAGE

stryke package · cdylib libstryke_mysql.{dylib,so} · dlopen'd in-process on use MySQL · opt-in (kept out of stryke core)

Report GitHub Issues
// Color scheme

>_STRYKE-MYSQL

SQL without the connection ceremony. MySQL / MariaDB client for stryke. Opt-in package, kept out of the stryke core binary so the daily-driver install stays slim. The mysql driver ships as a Rust cdylib that stryke dlopens in-process on first use MySQL.

Install

# from a release (no rustc on the consumer machine)
s pkg install -g github.com/MenkeTechnologies/stryke-mysql

# from a local checkout
cd ~/projects/stryke-mysql
cargo build --release            # produces target/release/libstryke_mysql.{dylib,so}
s pkg install -g .               # cdylib lands in ~/.stryke/store/mysql@<version>/

# one-liner
make install

The cdylib is dlopened in-process on first use MySQL — no helper-binary fork per call. Any stryke script that declares use MySQL resolves the package automatically.

Quick start: use MySQL

single-row scalarp MySQL::query_scalar "SELECT COUNT(*) FROM users"
query with bind paramsval @rows = MySQL::query "SELECT id, name FROM users WHERE created_at > ?", bind => ["2025-01-01"]
write pathval $r = MySQL::execute "UPDATE users SET name = ? WHERE id = ?", bind => ["alice", 42]
bulk insertMySQL::insert_many "users", [{ name => "x", score => 1 }, { name => "y", score => 2 }]
schema introspectionp to_json MySQL::schema "users"
discoveryp MySQL::tables |> ep

A worked-through script, mirroring examples/quick_query.stk and examples/crud.stk:

use MySQL

# Set $MYSQL_URL once, then omit the named arg everywhere.
$ENV{MYSQL_URL} = "mysql://root:secret@127.0.0.1:3306/test"

# Single-row scalar.
p MySQL::query_scalar "SELECT COUNT(*) FROM users"

# Rows with positional `?` binding.
val @rows = MySQL::query "SELECT id, name FROM users WHERE created_at > ?",
                        bind => ["2025-01-01"]
@rows |> ep

# Write paths return { affected, last_insert_id }.
val $r = MySQL::execute "UPDATE users SET name = ? WHERE id = ?",
                       bind => ["alice", 42]
p "updated $r->{affected}"

# Bulk insert (array of hashes; columns inferred from the first row's keys).
MySQL::insert_many "users",
    [{ name => "x", score => 1 },
     { name => "y", score => 2 }]

# Schema introspection.
p to_json MySQL::schema "users"
p MySQL::tables |> ep

The complete surface is 77 MySQL::* functions over 66 mysql__* FFI exports (package v0.21.0); it is also mirrored in the README "API reference" section.

Connecting

Every connection-aware function accepts the same connection keys in its %opts. The DSN is resolved in priority order:

1 — explicit URLurl => "mysql://user:pass@host:port/db"
2 — individual partshost => …, port => …, user => …, password => …, database => …
3 — environment$ENV{MYSQL_URL} (legacy $MYSQL_DSN is also honored by the examples)

The cdylib caches one mysql::Pool per resolved URL in a process-lifetime OnceCell, so repeated calls to the same DSN reuse the pool — no fresh TCP + auth handshake per call. Pure helpers (DSN parsing, identifier/literal quoting, type parsing) take no connection at all.

connectivity probeMySQL::ping — 1 | 0
package versionMySQL::version() — CARGO_PKG_VERSION of the cdylib
live server versionMySQL::server_version() — SELECT VERSION()
parse a DSNMySQL::parse_dsn "mysql://u:p@h:3306/db" — { scheme, user, password, host, port, database, params }
redact for loggingMySQL::redact_dsn $dsn — mysql://u:***@h/db

Read paths

All read helpers share %opts connection keys plus bind (an arrayref bound to positional ? placeholders). Rows come back as hashrefs keyed by column name.

querySELECT → @rows of hashrefs
query_oneSELECT LIMIT 1 → one row hashref or undef
query_colfirst column of every row → @values
query_scalarfirst cell of the first row → $value or undef
query_streamfires callback => fn { … } per row, returns the row count (cdylib returns all rows in one call)
query_multimulti-statement SQL → list of result sets, each { columns, rows }
callCALL proc(args) → every result set the procedure emits; opt args => [...]
dumpSELECT * FROM $table [LIMIT N] shorthand; opt limit
# one cell
val $n = MySQL::query_scalar "SELECT COUNT(*) FROM users"

# one row, or undef
val $u = MySQL::query_one "SELECT id, name FROM users WHERE id = ?", bind => [42]

# a flat column
val @ids = MySQL::query_col "SELECT id FROM users ORDER BY id"

# per-row callback (see examples/dump_table.stk)
MySQL::query_stream "SELECT * FROM big_table",
    callback => fn { p to_json _ }

Write & CRUD paths

Raw write statements return { affected, last_insert_id }; the higher-level CRUD helpers validate identifiers, bind values, and return an affected-row count.

executeany non-SELECT → { affected, last_insert_id }; opt bind
exec_fileread a multi-statement .sql script from a path and run it → { ok }
insert_manybulk INSERT an arrayref of hashrefs → inserted count (columns inferred from the first row)
upsertINSERT … ON DUPLICATE KEY UPDATE → affected count; opts conflict => \@cols, update => \@cols
updateUPDATE … SET … [WHERE]; bound SET, interpolated WHERE → affected count
deleteDELETE FROM … [WHERE] → affected count
truncateTRUNCATE TABLE → 1
countSELECT count(*) [WHERE $where] → row count
existsSELECT EXISTS(…) — short-circuits at the first match → 1 | 0
table_exists1 | 0 — $name must be a plain identifier
# bulk load (examples/bulk_load.stk)
val @rows = map { { name => "row_$_", v => $_ * 1.5 } } (1:100)
MySQL::insert_many "demo", \@rows

# upsert: insert, then bump only `hits` on a duplicate `id`
MySQL::upsert "kv", { id => 1, name => "a", hits => 1 }
MySQL::upsert "kv", { id => 1, name => "x", hits => 9 },
             conflict => ["id"], update => ["hits"]

# update / delete return the affected-row count
val $u = MySQL::update "users", { status => "active" }, "id = 42"
val $d = MySQL::delete "sessions", "expired_at < now()"

SET values and insert_many/upsert column values are bound; table and column names are identifier-validated. A $where string is interpolated — pass only trusted values there, or use execute with bind for a parameterized condition. MySQL's ON DUPLICATE KEY has no RETURNING (passing returning dies) and reports 1 for an insert, 2 for an update, 0 when a duplicate left the row unchanged.

Transactions

The pool may hand back-to-back FFI calls different pooled connections, so a hand-issued START TRANSACTION / COMMIT across separate calls would not share a session. MySQL::transaction runs an arrayref of { sql, params } statements on one checked-out connection inside a real START TRANSACTION: all commit together, any error rolls the whole batch back. It returns one { affected, last_insert_id } per statement.

MySQL::transaction [
    { sql => "UPDATE accounts SET bal = bal - 100 WHERE id = ?", params => [1] },
    { sql => "UPDATE accounts SET bal = bal + 100 WHERE id = ?", params => [2] },
], url => $dsn

Introspection & admin

Metadata helpers wrap SHOW … statements and information_schema queries; admin helpers cover process and database management. Most return plain rows (hashrefs) or name lists.

databases / tables / viewsname lists in the current database
schema / columns / column_namesDESCRIBE; full information_schema.COLUMNS rows; ordinal name list
indexes / foreign_keysSHOW INDEX; FK rows from key_column_usage + referential_constraints
create_tableSHOW CREATE TABLE → the full DDL string
procedures / triggers / eventsstored routines; SHOW TRIGGERS; SHOW EVENTS (scheduled)
charsets / collations / enginesSHOW CHARACTER SET / SHOW COLLATION / SHOW ENGINES
users / grantsaccounts from mysql.user; SHOW GRANTS FOR user@host (opt host, default "%")
status / variablesSHOW GLOBAL STATUS / VARIABLES; opt global => 0 for session scope
processlist / killSHOW FULL PROCESSLIST; KILL a thread → { id, killed }
db_size / table_sizecurrent-db byte size; { table, data_bytes, index_bytes, bytes }
explainEXPLAIN → plan rows; opt params
current_databaseSELECT DATABASE()
create_database / drop_databaseCREATE/DROP DATABASE [IF (NOT) EXISTS] → { database, created|dropped }

Pure helpers (no connection)

These run entirely in the cdylib with no server round-trip — DSN building, identifier/literal quoting, LIKE-pattern building, ENUM/SET encoding, and SQL-fragment builders. Each has an inverse where one exists.

DSNparse_dsnbuild_dsn, redact_dsn
identifiersquote_identunquote_ident, quote_qualified_identparse_qualified_ident
literalsquote_literalunquote_literal, quote (MySQL QUOTE()), escape_string (mysql_real_escape_string)
LIKEescape_likeunescape_like, like_pattern (contains / starts_with / ends_with / equals)
IN listsformat_in_listparse_in_list
ENUM / SETparse_enumbuild_enum, enum_indexenum_value, set_maskset_from_mask, parse_set_value
typesparse_column_type, normalize_type (drops integer display width)
SQL buildersformat_assignments ("col = ?"), format_placeholders ("(?, ?), (?, ?)"), build_where_eq ("col = ? AND …")
MySQL::quote_ident "weird`col"          # `weird``col`
MySQL::like_pattern "foo"               # %foo%
MySQL::like_pattern "foo", "starts_with" # foo%
MySQL::format_in_list ["a", "b"]        # ('a', 'b')
MySQL::parse_enum "enum('s','m','l')"   # { kind => "enum", values => ["s","m","l"], count => 3 }
MySQL::redact_dsn "mysql://u:s3cret@h/db" # mysql://u:***@h/db

Type encoding (MySQL → JSON)

Rows cross the FFI boundary as JSON, so column values arrive in stryke as these shapes:

INT, BIGINT, FLOAT, DOUBLEnumber
DECIMALstring (protocol sends bytes, decoded as UTF-8)
VARCHAR, TEXT, CHARstring
BLOB, VARBINARYstring — UTF-8 if valid, otherwise "base64:…"
DATE"YYYY-MM-DD"
DATETIME, TIMESTAMP"YYYY-MM-DD HH:MM:SS.ffffff"
TIME"[-]HHH:MM:SS.ffffff"
JSONstring (raw JSON text — from_json it stryke-side)
NULLnull

Why a package, not a builtin

The mysql crate pulls in a native libmysql-style protocol stack + TLS chains. Ships once as an opt-in package.

The stryke side is a thin .stk wrapper that calls mysql__* FFI symbols on the cdylib; the heavy code lives in libstryke_mysql.{dylib,so}, dlopened in-process on first use MySQL. Core stryke is never linked against this package's deps.

FFI layer

Each MySQL::* wrapper builds a JSON args dict and calls a sibling mysql__* symbol resolved out of libstryke_mysql.{dylib,so}. The cdylib is dlopened in-process on first use MySQL (via stryke's pkg::commands::try_load_ffi_for resolver hook); a mysql::Pool cache keyed by connection URL is held in OnceCell — no fork-per-call, no fresh TCP+auth handshake per call. The 66 exported mysql__* symbols cover the query/introspection surface plus the connection-free helpers; the authoritative list is [ffi].exports in stryke.toml.

Responses are JSON. A success payload carries the per-fn result shape; a failure comes back as {"error": "<msg>"} and the wrapper dies with MySQL::<op>: <reason>. The cdylib wraps every call in catch_unwind, so a Rust panic becomes a JSON error rather than a crash; returned C strings are freed via the exported stryke_free_cstring, wired automatically by rust_ffi::load_cdylib.

Errors & safety

Failures surface as a stryke die with the message MySQL::<op>: <reason>; guard with eval { … } and inspect $@ (see examples/discover.stk and examples/explain.stk, which skip cleanly when no server is reachable).

  • Values bound through bind / params and the values in insert_many, upsert, and update's SET hash are sent as positional ? parameters.
  • Table and column names in the CRUD helpers are validated against [A-Za-z_][A-Za-z0-9_]* and rejected otherwise.
  • A $where string is interpolated verbatim — pass only trusted/validated values there, or fall back to execute with a parameterized condition.

Tests

cargo test                       # unit + contract tests, no live calls
MYSQL_URL='mysql://…' s test t/  # end-to-end against a live MySQL

The end-to-end suite under t/ skips cleanly when $MYSQL_URL (or legacy $MYSQL_DSN) is unset or the server isn't reachable. Runnable examples live under examples/: quick_query.stk, bulk_load.stk, crud.stk, dump_table.stk, discover.stk, explain.stk.

Layout

stryke-mysql/
├── Cargo.toml             # cdylib crate manifest (crate-type = ["cdylib"], publish = false)
├── src/
│   └── lib.rs            # cdylib — mysql__* extern "C" exports
├── lib/                   # stryke .stk wrapper(s) — `use MySQL`
├── stryke.toml            # stryke package manifest
├── t/                     # zunit-style tests
├── examples/              # runnable .stk examples
├── Makefile               # `make install` builds + installs
└── docs/                  # this site (GitHub Pages)

Sibling packages

Part of the stryke package family. Browse the others via the MenkeTechnologiesMeta umbrella repo: