>_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 scalar | p MySQL::query_scalar "SELECT COUNT(*) FROM users" |
| query with bind params | val @rows = MySQL::query "SELECT id, name FROM users WHERE created_at > ?", bind => ["2025-01-01"] |
| write path | val $r = MySQL::execute "UPDATE users SET name = ? WHERE id = ?", bind => ["alice", 42] |
| bulk insert | MySQL::insert_many "users", [{ name => "x", score => 1 }, { name => "y", score => 2 }] |
| schema introspection | p to_json MySQL::schema "users" |
| discovery | p 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 URL | url => "mysql://user:pass@host:port/db" |
| 2 — individual parts | host => …, 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 probe | MySQL::ping — 1 | 0 |
| package version | MySQL::version() — CARGO_PKG_VERSION of the cdylib |
| live server version | MySQL::server_version() — SELECT VERSION() |
| parse a DSN | MySQL::parse_dsn "mysql://u:p@h:3306/db" — { scheme, user, password, host, port, database, params } |
| redact for logging | MySQL::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.
query | SELECT → @rows of hashrefs |
query_one | SELECT LIMIT 1 → one row hashref or undef |
query_col | first column of every row → @values |
query_scalar | first cell of the first row → $value or undef |
query_stream | fires callback => fn { … } per row, returns the row count (cdylib returns all rows in one call) |
query_multi | multi-statement SQL → list of result sets, each { columns, rows } |
call | CALL proc(args) → every result set the procedure emits; opt args => [...] |
dump | SELECT * 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.
execute | any non-SELECT → { affected, last_insert_id }; opt bind |
exec_file | read a multi-statement .sql script from a path and run it → { ok } |
insert_many | bulk INSERT an arrayref of hashrefs → inserted count (columns inferred from the first row) |
upsert | INSERT … ON DUPLICATE KEY UPDATE → affected count; opts conflict => \@cols, update => \@cols |
update | UPDATE … SET … [WHERE]; bound SET, interpolated WHERE → affected count |
delete | DELETE FROM … [WHERE] → affected count |
truncate | TRUNCATE TABLE → 1 |
count | SELECT count(*) [WHERE $where] → row count |
exists | SELECT EXISTS(…) — short-circuits at the first match → 1 | 0 |
table_exists | 1 | 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 / views | name lists in the current database |
schema / columns / column_names | DESCRIBE; full information_schema.COLUMNS rows; ordinal name list |
indexes / foreign_keys | SHOW INDEX; FK rows from key_column_usage + referential_constraints |
create_table | SHOW CREATE TABLE → the full DDL string |
procedures / triggers / events | stored routines; SHOW TRIGGERS; SHOW EVENTS (scheduled) |
charsets / collations / engines | SHOW CHARACTER SET / SHOW COLLATION / SHOW ENGINES |
users / grants | accounts from mysql.user; SHOW GRANTS FOR user@host (opt host, default "%") |
status / variables | SHOW GLOBAL STATUS / VARIABLES; opt global => 0 for session scope |
processlist / kill | SHOW FULL PROCESSLIST; KILL a thread → { id, killed } |
db_size / table_size | current-db byte size; { table, data_bytes, index_bytes, bytes } |
explain | EXPLAIN → plan rows; opt params |
current_database | SELECT DATABASE() |
create_database / drop_database | CREATE/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.
| DSN | parse_dsn ↔ build_dsn, redact_dsn |
| identifiers | quote_ident ↔ unquote_ident, quote_qualified_ident ↔ parse_qualified_ident |
| literals | quote_literal ↔ unquote_literal, quote (MySQL QUOTE()), escape_string (mysql_real_escape_string) |
| LIKE | escape_like ↔ unescape_like, like_pattern (contains / starts_with / ends_with / equals) |
| IN lists | format_in_list ↔ parse_in_list |
| ENUM / SET | parse_enum ↔ build_enum, enum_index ↔ enum_value, set_mask ↔ set_from_mask, parse_set_value |
| types | parse_column_type, normalize_type (drops integer display width) |
| SQL builders | format_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, DOUBLE | number |
| DECIMAL | string (protocol sends bytes, decoded as UTF-8) |
| VARCHAR, TEXT, CHAR | string |
| BLOB, VARBINARY | string — UTF-8 if valid, otherwise "base64:…" |
| DATE | "YYYY-MM-DD" |
| DATETIME, TIMESTAMP | "YYYY-MM-DD HH:MM:SS.ffffff" |
| TIME | "[-]HHH:MM:SS.ffffff" |
| JSON | string (raw JSON text — from_json it stryke-side) |
| NULL | null |
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/paramsand the values ininsert_many,upsert, andupdate'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
$wherestring is interpolated verbatim — pass only trusted/validated values there, or fall back toexecutewith 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:
- stryke-arrow — Apache Arrow / Parquet / Feather
- stryke-aws — S3, DynamoDB, SQS, Lambda, STS
- stryke-azure — Blob, Queues, Cosmos DB, Key Vault, Entra
- stryke-clickhouse — ClickHouse
- stryke-demo — live demos for every connector
- stryke-docker — Docker daemon API
- stryke-duckdb — embedded DuckDB SQL engine
- stryke-email — email + campaign client
- stryke-fleet — parallel expect/PTY automation
- stryke-gcp — Cloud Storage, Pub/Sub, Secret Manager, BigQuery, Firestore
- stryke-grpc — reflection-based gRPC client
- stryke-gui — mouse / keyboard / screen / clipboard automation
- stryke-k8s — Kubernetes
- stryke-kafka — Apache Kafka
- stryke-mcpd — MCP servers without a runtime
- stryke-mongo — MongoDB
- stryke-mssql — Microsoft SQL Server
- stryke-neo4j — Neo4j graph (Bolt)
- stryke-office — Office docs / PDF / images
- stryke-parquet — Parquet file inspector
- stryke-polars — Polars + ndarray + linalg + FFT
- stryke-postgres — PostgreSQL
- stryke-redis — Redis / Valkey
- stryke-scrape — web scraping / crawling
- stryke-scylla — ScyllaDB / Cassandra
- stryke-search — Elasticsearch / OpenSearch
- stryke-selenium — browser automation (WebDriver)
- stryke-spark — Spark Connect (no JVM)
- stryke-utils — pure-stryke utility belt
- stryke-zmq — ZeroMQ messaging